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 16 commits into
base: trunk
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
7 changes: 4 additions & 3 deletions packages/js/src/components/MetaboxCollapsible.js
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -26,8 +27,8 @@ const StyledMetaboxCollapsible = styled( Collapsible )`
*
* @returns {React.Component} A MetaboxCollapsible component
*/
const MetaboxCollapsible = ( props ) => {
return <StyledMetaboxCollapsible hasPadding={ true } hasSeparator={ true } { ...props } />;
};
const MetaboxCollapsible = forwardRef( ( props, ref ) => {
return <StyledMetaboxCollapsible hasPadding={ true } hasSeparator={ true } { ...props } ref={ ref } />;
} );

export default MetaboxCollapsible;
7 changes: 4 additions & 3 deletions packages/js/src/components/SidebarCollapsible.js
Original file line number Diff line number Diff line change
@@ -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 */
Expand All @@ -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 {
Expand All @@ -33,6 +33,7 @@ const SidebarCollapsible = ( props ) => {
className="components-button components-panel__body-toggle"
type="button"
id={ props.buttonId }
ref={ ref }
>
<span
className="yoast-icon-span"
Expand All @@ -56,7 +57,7 @@ const SidebarCollapsible = ( props ) => {
</h2>
{ isOpen && props.children }
</div>;
};
} );
/* eslint-enable complexity */

export default SidebarCollapsible;
Expand Down
26 changes: 26 additions & 0 deletions packages/js/src/components/contentAnalysis/SeoAnalysis.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 React from "react";

const AnalysisHeader = styled.span`
font-size: 1em;
Expand All @@ -34,6 +35,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.
*
Expand Down Expand Up @@ -206,6 +213,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 + "AIFixes" );
const isPremium = getL10nObject().isPremium;

// Don't show the button if the AI feature is not enabled for Yoast SEO Premium users.
Expand All @@ -220,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.
Expand Down Expand Up @@ -260,6 +281,7 @@ class SeoAnalysis extends Component {
prefixIconCollapsed={ getIconForScore( score.className ) }
subTitle={ this.props.keyword }
id={ `yoast-seo-analysis-collapsible-${ location }` }
ref={ this.collapsibleRef }
>
<SynonymSlot location={ location } />
{ this.props.shouldUpsell && <Fragment>
Expand Down Expand Up @@ -298,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,
Expand All @@ -324,14 +347,17 @@ export default withSelect( ( select, ownProps ) => {
getMarksButtonStatus,
getResultsForKeyword,
getIsElementorEditor,
getFocusAIFixesButton,
} = select( "yoast-seo/editor" );

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

return {
...getResultsForKeyword( keyword ),
marksButtonStatus: ownProps.hideMarksButtons ? "disabled" : getMarksButtonStatus(),
keyword,
focusAIFixesButton,
isElementor: getIsElementorEditor(),
isAiFeatureEnabled: select( "yoast-seo-premium/editor" )?.getIsAiFeatureEnabled(),
};
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,
};
}
13 changes: 11 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,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 {
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
Loading
Loading