Commit 4fd8a76244e for woocommerce
commit 4fd8a76244eb108b092e625c5d64fa5290889304
Author: Poli Gilad <83961704+poligilad-auto@users.noreply.github.com>
Date: Fri Jun 26 09:57:31 2026 +0200
Fix express payment button focus outline (#65901)
* Fix express payment focus outline
* Add changelog entry for express payment focus outline
* Fix express payment iframe focus outline
* Fix express payment iframe focus state
* Handle express iframe focus via native events
* Clear stale express iframe focus state
* Sync express iframe focus while active
* Address express payment focus test feedback
* Extract express payment focus handling
* Improve express payment focus contrast
* Align express focus color with buttons
diff --git a/plugins/woocommerce/changelog/fix-express-payment-focus-outline b/plugins/woocommerce/changelog/fix-express-payment-focus-outline
new file mode 100644
index 00000000000..cbe83d0d6a7
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-express-payment-focus-outline
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix express payment buttons missing a visible focus outline in Cart and Checkout.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/express-payment-methods.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/express-payment-methods.tsx
index 256923aefb9..15f3b3ebcd3 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/express-payment-methods.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/express-payment-methods.tsx
@@ -15,6 +15,7 @@ import {
import { useEditorContext } from '@woocommerce/base-context';
import deprecated from '@wordpress/deprecated';
import { useDispatch, useSelect } from '@wordpress/data';
+import clsx from 'clsx';
import {
ActionCreatorsOf,
ConfigOf,
@@ -28,6 +29,7 @@ import PaymentMethodErrorBoundary from './payment-method-error-boundary';
import { STORE_KEY as PAYMENT_STORE_KEY } from '../../../data/payment/constants';
import { useExpressPaymentContext } from '../../cart-checkout-shared/payment-methods/express-payment/express-payment-context';
import type { PaymentStoreDescriptor } from '../../../data/payment';
+import { useExpressPaymentFocus } from './use-express-payment-focus';
const ExpressPaymentMethods = () => {
const { isEditor } = useEditorContext();
@@ -69,6 +71,9 @@ const ExpressPaymentMethods = () => {
const paymentMethodInterface = usePaymentMethodInterface();
const previousActivePaymentMethod = useRef( activePaymentMethod );
const previousPaymentMethodData = useRef( paymentMethodData );
+ const entries = Object.entries( paymentMethods );
+ const { expressPaymentWrapperRef, focusedExpressPaymentMethod } =
+ useExpressPaymentFocus( ! isEditor && entries.length > 0 );
/**
* onExpressPaymentClick should be triggered when the express payment button is clicked.
@@ -156,7 +161,6 @@ const ExpressPaymentMethods = () => {
* Currently re-renders excessively but is not easy to useMemo because paymentMethodInterface could become stale.
* paymentMethodInterface itself also updates on most renders.
*/
- const entries = Object.entries( paymentMethods );
/*
* Define the elements used for the Express Payments markup.
*
@@ -181,6 +185,10 @@ const ExpressPaymentMethods = () => {
<ExpressPayItem
key={ id }
id={ `express-payment-method-${ id }` }
+ className={ clsx( {
+ 'wc-block-components-express-payment__event-button--focused':
+ focusedExpressPaymentMethod === id,
+ } ) }
>
{ cloneElement( expressPaymentMethod, {
...paymentMethodInterface,
@@ -202,7 +210,10 @@ const ExpressPaymentMethods = () => {
return (
<PaymentMethodErrorBoundary isEditor={ isEditor }>
- <ExpressPayWrapper className="wc-block-components-express-payment__event-buttons">
+ <ExpressPayWrapper
+ className="wc-block-components-express-payment__event-buttons"
+ ref={ expressPaymentWrapperRef }
+ >
{ content }
</ExpressPayWrapper>
</PaymentMethodErrorBoundary>
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/express-payment/style.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/express-payment/style.scss
index 496d2b5eea6..b0cdb3ce7a8 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/express-payment/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/express-payment/style.scss
@@ -16,12 +16,27 @@ $border-width: 1px;
margin: 0;
width: 100%;
overflow: hidden;
+ border-radius: $universal-border-radius;
+ position: relative;
// ensure the buttons don't create a horizontal layout shift
min-height: 48px;
// ensure the elements take space before the button is loaded
// 150 is the minimum width of the button; allows for
min-width: 150px;
+ &:focus-within::after,
+ &.wc-block-components-express-payment__event-button--focused::after {
+ border: 2px solid $studio-blue;
+ border-radius: inherit;
+ box-shadow: inset 0 0 0 1px $white;
+ content: "";
+ inset: 0;
+ outline: 3px solid transparent;
+ pointer-events: none;
+ position: absolute;
+ z-index: 1;
+ }
+
> img {
width: 100%;
height: 48px;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/test/express-payment-methods.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/test/express-payment-methods.tsx
index 8f628b45149..4fec31c85fd 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/test/express-payment-methods.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/test/express-payment-methods.tsx
@@ -1,7 +1,13 @@
/**
* External dependencies
*/
-import { render, screen } from '@testing-library/react';
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from '@testing-library/react';
import { paymentStore } from '@woocommerce/block-data';
import {
registerExpressPaymentMethod,
@@ -34,7 +40,7 @@ jest.mock( '../express-payment/express-payment-context', () => {
const mockExpressPaymentMethodNames = [ 'paypal', 'google-pay', 'apple-pay' ];
const MockExpressButton = jest.fn( ( { name } ) => (
- <div className="boo">{ `${ name } button` }</div>
+ <button className="boo">{ `${ name } button` }</button>
) );
const MockEditorExpressButton = jest.fn( ( { name } ) => (
@@ -253,6 +259,79 @@ describe( 'Express payment methods', () => {
).toHaveProperty( 'tagName', 'LI' );
} );
} );
+
+ it( 'should add a focused class to the active express payment method item', async () => {
+ render( <ExpressPaymentMethods /> );
+
+ const button = screen.getByText( 'paypal button' );
+ const paymentMethodItem = document.querySelector(
+ '#express-payment-method-paypal'
+ );
+
+ await act( async () => {
+ button.focus();
+ fireEvent.focusIn( button );
+ } );
+
+ await waitFor( () =>
+ expect( paymentMethodItem ).toHaveClass(
+ 'wc-block-components-express-payment__event-button--focused'
+ )
+ );
+
+ await act( async () => {
+ button.blur();
+ fireEvent.focusOut( button );
+ await new Promise( ( resolve ) =>
+ setTimeout( resolve, 0 )
+ );
+ } );
+
+ await waitFor( () =>
+ expect( paymentMethodItem ).not.toHaveClass(
+ 'wc-block-components-express-payment__event-button--focused'
+ )
+ );
+ } );
+
+ it( 'should keep a focused express payment iframe when the window loses focus', async () => {
+ render( <ExpressPaymentMethods /> );
+
+ const paymentMethodItem = document.querySelector(
+ '#express-payment-method-paypal'
+ );
+ expect( paymentMethodItem ).not.toBeNull();
+
+ if ( ! paymentMethodItem ) {
+ throw new Error(
+ 'Missing #express-payment-method-paypal in test DOM'
+ );
+ }
+
+ const iframe = document.createElement( 'iframe' );
+ paymentMethodItem.appendChild( iframe );
+
+ await act( async () => {
+ iframe.focus();
+ fireEvent.focusIn( iframe );
+ } );
+
+ await waitFor( () =>
+ expect( paymentMethodItem ).toHaveClass(
+ 'wc-block-components-express-payment__event-button--focused'
+ )
+ );
+
+ await act( async () => {
+ window.dispatchEvent( new Event( 'blur' ) );
+ } );
+
+ await waitFor( () =>
+ expect( paymentMethodItem ).toHaveClass(
+ 'wc-block-components-express-payment__event-button--focused'
+ )
+ );
+ } );
} );
describe( 'In an editor context', () => {
beforeEach( () => {
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/use-express-payment-focus.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/use-express-payment-focus.ts
new file mode 100644
index 00000000000..01f78d2968b
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/use-express-payment-focus.ts
@@ -0,0 +1,127 @@
+/**
+ * External dependencies
+ */
+import { useCallback, useEffect, useRef, useState } from '@wordpress/element';
+
+type ExpressPaymentFocus = {
+ expressPaymentWrapperRef: React.MutableRefObject< HTMLElement | null >;
+ focusedExpressPaymentMethod: string | null;
+};
+
+export const useExpressPaymentFocus = (
+ enabled: boolean
+): ExpressPaymentFocus => {
+ const expressPaymentWrapperRef = useRef< HTMLElement | null >( null );
+ const focusSyncIntervalRef = useRef< ReturnType<
+ typeof setInterval
+ > | null >( null );
+ const [ focusedExpressPaymentMethod, setFocusedExpressPaymentMethod ] =
+ useState< string | null >( null );
+
+ const stopFocusSyncInterval = useCallback( () => {
+ if ( focusSyncIntervalRef.current ) {
+ clearInterval( focusSyncIntervalRef.current );
+ focusSyncIntervalRef.current = null;
+ }
+ }, [] );
+
+ const getFocusedExpressPaymentMethod = useCallback( () => {
+ const wrapper = expressPaymentWrapperRef.current;
+ const activeElement = wrapper?.ownerDocument.activeElement;
+
+ if ( ! wrapper || ! ( activeElement instanceof Element ) ) {
+ return {
+ focusedPaymentMethod: null,
+ isExpressPaymentIframe: false,
+ };
+ }
+
+ const item = activeElement.closest( '[id^="express-payment-method-"]' );
+ const isExpressPaymentIframe =
+ activeElement instanceof HTMLIFrameElement &&
+ wrapper.contains( activeElement );
+
+ if ( item && wrapper.contains( item ) ) {
+ return {
+ focusedPaymentMethod: item.id.replace(
+ 'express-payment-method-',
+ ''
+ ),
+ isExpressPaymentIframe,
+ };
+ }
+
+ return {
+ focusedPaymentMethod: null,
+ isExpressPaymentIframe: false,
+ };
+ }, [] );
+
+ const syncFocusedExpressPaymentMethod = useCallback( () => {
+ const { focusedPaymentMethod, isExpressPaymentIframe } =
+ getFocusedExpressPaymentMethod();
+
+ setFocusedExpressPaymentMethod( focusedPaymentMethod );
+
+ if ( ! isExpressPaymentIframe ) {
+ stopFocusSyncInterval();
+ return;
+ }
+
+ if ( ! focusSyncIntervalRef.current ) {
+ focusSyncIntervalRef.current = setInterval( () => {
+ const {
+ focusedPaymentMethod: nextFocusedPaymentMethod,
+ isExpressPaymentIframe: nextIsExpressPaymentIframe,
+ } = getFocusedExpressPaymentMethod();
+
+ setFocusedExpressPaymentMethod( nextFocusedPaymentMethod );
+
+ if ( ! nextIsExpressPaymentIframe ) {
+ stopFocusSyncInterval();
+ }
+ }, 100 );
+ }
+ }, [ getFocusedExpressPaymentMethod, stopFocusSyncInterval ] );
+
+ useEffect( () => {
+ if ( ! enabled ) {
+ setFocusedExpressPaymentMethod( null );
+ stopFocusSyncInterval();
+ return;
+ }
+
+ const wrapper = expressPaymentWrapperRef.current;
+
+ if ( ! wrapper ) {
+ return;
+ }
+
+ const doc = wrapper.ownerDocument;
+ const win = doc.defaultView;
+ const syncSoon = () => {
+ setTimeout( syncFocusedExpressPaymentMethod, 0 );
+ };
+
+ doc.addEventListener( 'focusin', syncFocusedExpressPaymentMethod );
+ doc.addEventListener( 'focusout', syncSoon );
+ win?.addEventListener( 'blur', syncFocusedExpressPaymentMethod );
+ win?.addEventListener( 'focus', syncSoon );
+
+ return () => {
+ stopFocusSyncInterval();
+ doc.removeEventListener(
+ 'focusin',
+ syncFocusedExpressPaymentMethod
+ );
+ doc.removeEventListener( 'focusout', syncSoon );
+ win?.removeEventListener( 'blur', syncFocusedExpressPaymentMethod );
+ win?.removeEventListener( 'focus', syncSoon );
+ };
+ }, [ enabled, stopFocusSyncInterval, syncFocusedExpressPaymentMethod ] );
+
+ return {
+ expressPaymentWrapperRef,
+ focusedExpressPaymentMethod,
+ };
+};