Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
1 change: 1 addition & 0 deletions packages/js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"@wordpress/components": "^28.8.12",
"@wordpress/compose": "^7.8.4",
"@wordpress/data": "^10.8.4",
"@wordpress/dom": "^4.27.0",
"@wordpress/dom-ready": "^4.8.1",
"@wordpress/editor": "^14.33.10",
"@wordpress/element": "^6.8.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { OneSparkNote } from "./one-spark-note";
*/
export const InlineBanner = ( { isPremium, onDismiss, onClick } ) => {
const ariaProps = useSvgAria();
return <Root><div className="yst-z-50 yst-relative yst-p-4 yst-ai-gradient-border yst-rounded-lg yst-max-w">
return <Root><div role="group" aria-label={ __( "Content suggestions banner", "wordpress-seo" ) } className="yst-z-50 yst-relative yst-p-4 yst-ai-gradient-border yst-rounded-lg yst-max-w-xl">
<div className="yst-flex yst-items-center yst-gap-2 yst-mb-1">
<GradientSparklesIcon className="yst-h-4 yst-w-4" { ...ariaProps } />
<p className="yst-grow yst-text-slate-800 yst-font-medium"> { __( "Stuck on what to write next?", "wordpress-seo" ) }</p>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
/* eslint-disable jsdoc/require-jsdoc */
Comment thread
vraja-pro marked this conversation as resolved.
Outdated
import { createHigherOrderComponent } from "@wordpress/compose";
import { useSelect, useDispatch } from "@wordpress/data";
import { useCallback, useEffect, useRef } from "@wordpress/element";
import { InlineBanner } from "./inline-banner";
import { CONTENT_PLANNER_STORE, FEATURE_MODAL_STATUS, INJECTED_STYLE_ID } from "../constants";
import { STORE_NAME_AI, STORE_NAME_EDITOR } from "../../ai-generator/constants";
import { useFetchContentSuggestions } from "../hooks/use-fetch-content-suggestions";
import { handleBannerTabNavigation } from "../helpers/handle-banner-tab-navigation";

/**
* The component that conditionally renders the Content Planner inline banner and injects the Tailwind stylesheet into the editor iframe.
Expand Down Expand Up @@ -67,10 +69,34 @@ const FirstBlockWithBanner = ( { BlockListBlock, props } ) => {
ownerDoc.head.appendChild( link );
}, [ shouldShow ] );

useEffect( () => {
if ( ! shouldShow ) {
return;
}

// Gutenberg's writing-flow Tab handler runs in the bubble phase and redirects
// focus to sentinel divs when the next tabbable is not inside the same block.
// The banner sits outside any [data-block] block wrapper, so it is always
// skipped. Attaching a capture-phase listener lets us act before Gutenberg does;
// once we call preventDefault(), Gutenberg's early-return guard fires and leaves
// focus alone.
const ownerDoc = ref.current?.ownerDocument;
if ( ! ownerDoc ) {
return;
}

function handleTabNavigation( event ) {
handleBannerTabNavigation( ref.current, event );
}

ownerDoc.addEventListener( "keydown", handleTabNavigation, { capture: true } );
return () => ownerDoc.removeEventListener( "keydown", handleTabNavigation, { capture: true } );
}, [ shouldShow ] );

return (
<>
{ shouldShow && (
<div ref={ ref } className="wp-block">
<div ref={ ref } className="wp-block" data-block="yoast-content-planner-banner">
<InlineBanner
isPremium={ isPremium }
onDismiss={ handleDismiss }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { focus } from "@wordpress/dom";

/**
* Keydown handler that keeps the inline banner reachable via Tab inside the Gutenberg
* writing flow. Gutenberg's useTabNav hook intercepts Tab in the bubble phase and
* redirects focus to sentinel divs when the next tabbable element is not inside the
* same [data-block] wrapper. Because the banner sits outside any real block, it is
* normally skipped. This handler is meant to be attached in the capture phase so it
* runs before Gutenberg; once it calls preventDefault(), Gutenberg's early-return guard
* fires and leaves focus alone.
*
* @param {HTMLElement} bannerEl The banner wrapper element.
* @param {KeyboardEvent} event The keydown event.
* @returns {void}
*/
// eslint-disable-next-line complexity
Comment thread
vraja-pro marked this conversation as resolved.
Outdated
export function handleBannerTabNavigation( bannerEl, event ) {
if ( event.defaultPrevented || event.keyCode !== 9 || ! bannerEl ) {
Comment thread
vraja-pro marked this conversation as resolved.
Outdated
return;
}

const findSibling = event.shiftKey ? focus.tabbable.findPrevious : focus.tabbable.findNext;
const next = findSibling( event.target );
if ( ! next ) {
Comment thread
vraja-pro marked this conversation as resolved.
Outdated
return;
}

// Intercept only when Tab or Shift+Tab crosses the banner boundary (entering or leaving).
// Intra-banner navigation is already handled by Gutenberg via the data-block attribute.
if ( bannerEl.contains( event.target ) !== bannerEl.contains( next ) ) {
Comment thread
vraja-pro marked this conversation as resolved.
Outdated
event.preventDefault();
next.focus();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { handleBannerTabNavigation } from "../../../src/ai-content-planner/helpers/handle-banner-tab-navigation";

const mockFindNext = jest.fn();
const mockFindPrevious = jest.fn();

jest.mock( "@wordpress/dom", () => ( {
focus: {
tabbable: {
findNext: ( ...args ) => mockFindNext( ...args ),
findPrevious: ( ...args ) => mockFindPrevious( ...args ),
},
},
} ) );

/**
* Creates a minimal keydown event mock.
* @param {object} overrides Properties to override on the default Tab event.
* @returns {object} The event mock.
*/
const makeEvent = ( overrides = {} ) => ( {
defaultPrevented: false,
keyCode: 9,
shiftKey: false,
target: document.createElement( "button" ),
preventDefault: jest.fn(),
...overrides,
} );
Comment thread
vraja-pro marked this conversation as resolved.

describe( "handleBannerTabNavigation", () => {
let bannerEl;
let insideButton;
let outsideButton;

beforeEach( () => {
bannerEl = document.createElement( "div" );
insideButton = document.createElement( "button" );
outsideButton = document.createElement( "button" );

bannerEl.appendChild( insideButton );
document.body.appendChild( bannerEl );
document.body.appendChild( outsideButton );

mockFindNext.mockReset();
mockFindPrevious.mockReset();
} );

afterEach( () => {
document.body.innerHTML = "";
} );

describe( "early returns", () => {
it( "does nothing when event.defaultPrevented is true", () => {
const event = makeEvent( { defaultPrevented: true } );
handleBannerTabNavigation( bannerEl, event );
expect( mockFindNext ).not.toHaveBeenCalled();
expect( event.preventDefault ).not.toHaveBeenCalled();
} );

it( "does nothing for non-Tab keys", () => {
const event = makeEvent( { keyCode: 13 } );
handleBannerTabNavigation( bannerEl, event );
expect( mockFindNext ).not.toHaveBeenCalled();
expect( event.preventDefault ).not.toHaveBeenCalled();
} );

it( "does nothing when bannerEl is null", () => {
const event = makeEvent();
handleBannerTabNavigation( null, event );
expect( mockFindNext ).not.toHaveBeenCalled();
expect( event.preventDefault ).not.toHaveBeenCalled();
} );

it( "does nothing when there is no next tabbable element", () => {
mockFindNext.mockReturnValue( null );
const event = makeEvent( { target: outsideButton } );
handleBannerTabNavigation( bannerEl, event );
expect( event.preventDefault ).not.toHaveBeenCalled();
} );

it( "does nothing when there is no previous tabbable element (Shift+Tab)", () => {
mockFindPrevious.mockReturnValue( null );
const event = makeEvent( { shiftKey: true, target: outsideButton } );
handleBannerTabNavigation( bannerEl, event );
expect( event.preventDefault ).not.toHaveBeenCalled();
} );
} );

describe( "Tab into banner", () => {
it( "focuses the first banner button and prevents default when tabbing forward from outside", () => {
mockFindNext.mockReturnValue( insideButton );
const focusSpy = jest.spyOn( insideButton, "focus" );
const event = makeEvent( { target: outsideButton } );

handleBannerTabNavigation( bannerEl, event );

expect( event.preventDefault ).toHaveBeenCalledTimes( 1 );
expect( focusSpy ).toHaveBeenCalledTimes( 1 );
} );

it( "focuses the last banner button and prevents default when shift-tabbing backward from outside", () => {
mockFindPrevious.mockReturnValue( insideButton );
const focusSpy = jest.spyOn( insideButton, "focus" );
const event = makeEvent( { shiftKey: true, target: outsideButton } );

handleBannerTabNavigation( bannerEl, event );

expect( event.preventDefault ).toHaveBeenCalledTimes( 1 );
expect( focusSpy ).toHaveBeenCalledTimes( 1 );
} );
} );

describe( "Tab out of banner", () => {
it( "focuses the next element outside and prevents default when tabbing forward from the last button", () => {
mockFindNext.mockReturnValue( outsideButton );
const focusSpy = jest.spyOn( outsideButton, "focus" );
const event = makeEvent( { target: insideButton } );

handleBannerTabNavigation( bannerEl, event );

expect( event.preventDefault ).toHaveBeenCalledTimes( 1 );
expect( focusSpy ).toHaveBeenCalledTimes( 1 );
} );

it( "focuses the previous element outside and prevents default when shift-tabbing from the first button", () => {
mockFindPrevious.mockReturnValue( outsideButton );
const focusSpy = jest.spyOn( outsideButton, "focus" );
const event = makeEvent( { shiftKey: true, target: insideButton } );

handleBannerTabNavigation( bannerEl, event );

expect( event.preventDefault ).toHaveBeenCalledTimes( 1 );
expect( focusSpy ).toHaveBeenCalledTimes( 1 );
} );
} );

describe( "Tab within banner", () => {
it( "does not intercept when both current and next tabbable are inside the banner", () => {
const secondInsideButton = document.createElement( "button" );
bannerEl.appendChild( secondInsideButton );
mockFindNext.mockReturnValue( secondInsideButton );
const focusSpy = jest.spyOn( secondInsideButton, "focus" );
const event = makeEvent( { target: insideButton } );

handleBannerTabNavigation( bannerEl, event );

expect( event.preventDefault ).not.toHaveBeenCalled();
expect( focusSpy ).not.toHaveBeenCalled();
} );

it( "does not intercept when both current and previous tabbable are inside the banner (Shift+Tab)", () => {
const secondInsideButton = document.createElement( "button" );
bannerEl.appendChild( secondInsideButton );
mockFindPrevious.mockReturnValue( insideButton );
const focusSpy = jest.spyOn( insideButton, "focus" );
const event = makeEvent( { shiftKey: true, target: secondInsideButton } );

handleBannerTabNavigation( bannerEl, event );

expect( event.preventDefault ).not.toHaveBeenCalled();
expect( focusSpy ).not.toHaveBeenCalled();
} );
} );

describe( "Tab outside banner entirely", () => {
it( "does not intercept when both current and next tabbable are outside the banner", () => {
const anotherOutsideButton = document.createElement( "button" );
document.body.appendChild( anotherOutsideButton );
mockFindNext.mockReturnValue( anotherOutsideButton );
const focusSpy = jest.spyOn( anotherOutsideButton, "focus" );
const event = makeEvent( { target: outsideButton } );

handleBannerTabNavigation( bannerEl, event );

expect( event.preventDefault ).not.toHaveBeenCalled();
expect( focusSpy ).not.toHaveBeenCalled();
} );

it( "does not intercept when both current and previous tabbable are outside the banner (Shift+Tab)", () => {
const anotherOutsideButton = document.createElement( "button" );
document.body.appendChild( anotherOutsideButton );
mockFindPrevious.mockReturnValue( anotherOutsideButton );
const focusSpy = jest.spyOn( anotherOutsideButton, "focus" );
const event = makeEvent( { shiftKey: true, target: outsideButton } );

handleBannerTabNavigation( bannerEl, event );

expect( event.preventDefault ).not.toHaveBeenCalled();
expect( focusSpy ).not.toHaveBeenCalled();
} );
} );
} );
Loading