Commit 629cbd73f04 for woocommerce
commit 629cbd73f04523768d0c4c1da4b2ac730305c44e
Author: Alefe Souza <contact@alefesouza.com>
Date: Mon Sep 14 16:59:16 2026 -0300
Fix the checkout order summary collapsible bar being inert before its width is measured (#68531)
diff --git a/plugins/woocommerce/changelog/68530-order-summary-collapsible-bar-keyboard-defaults b/plugins/woocommerce/changelog/68530-order-summary-collapsible-bar-keyboard-defaults
new file mode 100644
index 00000000000..64d55708cc2
--- /dev/null
+++ b/plugins/woocommerce/changelog/68530-order-summary-collapsible-bar-keyboard-defaults
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Checkout block: stop Space scrolling the page and Enter submitting the form when toggling the collapsible order summary.
diff --git a/plugins/woocommerce/changelog/68530-order-summary-collapsible-bar-operable b/plugins/woocommerce/changelog/68530-order-summary-collapsible-bar-operable
new file mode 100644
index 00000000000..349b9f78c23
--- /dev/null
+++ b/plugins/woocommerce/changelog/68530-order-summary-collapsible-bar-operable
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Checkout block: keep the collapsible order summary expandable by mouse and keyboard before the container width has been measured.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-block/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-block/edit.tsx
index 2b1c35ff287..8f5ab602a6a 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-block/edit.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-block/edit.tsx
@@ -8,12 +8,10 @@ import { TotalsFooterItem } from '@woocommerce/base-components/cart-checkout';
import { getCurrencyFromPriceResponse } from '@woocommerce/price-format';
import { useStoreCart } from '@woocommerce/base-context/hooks';
import { __ } from '@wordpress/i18n';
-import { useId, useState } from '@wordpress/element';
import { Icon } from '@wordpress/components';
import { chevronDown, chevronUp } from '@wordpress/icons';
import clsx from 'clsx';
import { FormattedMonetaryAmount } from '@woocommerce/blocks-components';
-import { useContainerWidthContext } from '@woocommerce/base-context';
/**
* Internal dependencies
@@ -23,6 +21,7 @@ import {
getAllowedBlocks,
} from '../../../cart-checkout-shared';
import { OrderMetaSlotFill } from './slotfills';
+import { useOrderSummaryToggle } from './use-order-summary-toggle';
export const Edit = ( { clientId }: { clientId: string } ): JSX.Element => {
const blockProps = useBlockProps();
@@ -32,24 +31,7 @@ export const Edit = ( { clientId }: { clientId: string } ): JSX.Element => {
const allowedBlocks = getAllowedBlocks(
innerBlockAreas.CHECKOUT_ORDER_SUMMARY
);
- const { isLarge } = useContainerWidthContext();
- const [ isOpen, setIsOpen ] = useState( false );
- const ariaControlsId = useId();
-
- const orderSummaryProps = ! isLarge
- ? {
- role: 'button',
- onClick: () => setIsOpen( ! isOpen ),
- 'aria-expanded': isOpen,
- 'aria-controls': ariaControlsId,
- tabIndex: 0,
- onKeyDown: ( event: React.KeyboardEvent ) => {
- if ( event.key === 'Enter' || event.key === ' ' ) {
- setIsOpen( ! isOpen );
- }
- },
- }
- : {};
+ const { isOpen, ariaControlsId, toggleProps } = useOrderSummaryToggle();
const defaultTemplate = [
[ 'woocommerce/checkout-order-summary-cart-items-block', {}, [] ],
@@ -67,7 +49,7 @@ export const Edit = ( { clientId }: { clientId: string } ): JSX.Element => {
<div { ...blockProps }>
<div
className="wc-block-components-checkout-order-summary__title"
- { ...orderSummaryProps }
+ { ...toggleProps }
>
<p
className="wc-block-components-checkout-order-summary__title-text"
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-block/frontend.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-block/frontend.tsx
index dee1585f1a4..c0be0bac6f0 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-block/frontend.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-block/frontend.tsx
@@ -6,15 +6,14 @@ import { getCurrencyFromPriceResponse } from '@woocommerce/price-format';
import { useStoreCart } from '@woocommerce/base-context/hooks';
import { __ } from '@wordpress/i18n';
import { Icon, chevronDown, chevronUp } from '@wordpress/icons';
-import { useId, useState } from '@wordpress/element';
import clsx from 'clsx';
import { FormattedMonetaryAmount } from '@woocommerce/blocks-components';
/**
* Internal dependencies
*/
import { OrderMetaSlotFill, CheckoutOrderSummaryFill } from './slotfills';
-import { useContainerWidthContext } from '../../../../base/context';
import { FormStepHeading } from '../../form-step';
+import { useOrderSummaryToggle } from './use-order-summary-toggle';
const FrontendBlock = ( {
children,
@@ -24,31 +23,15 @@ const FrontendBlock = ( {
className?: string;
} ): JSX.Element | null => {
const { cartTotals } = useStoreCart();
- const { isMedium, isSmall, isMobile } = useContainerWidthContext();
- const [ isOpen, setIsOpen ] = useState( false );
+ const { isOpen, isLarge, ariaControlsId, toggleProps } =
+ useOrderSummaryToggle();
const totalsCurrency = getCurrencyFromPriceResponse( cartTotals );
const totalPrice = parseInt( cartTotals.total_price, 10 );
- const ariaControlsId = useId();
- const orderSummaryProps =
- isMedium || isSmall || isMobile
- ? {
- role: 'button',
- onClick: () => setIsOpen( ! isOpen ),
- 'aria-expanded': isOpen,
- 'aria-controls': ariaControlsId,
- tabIndex: 0,
- onKeyDown: ( event: React.KeyboardEvent ) => {
- if ( event.key === 'Enter' || event.key === ' ' ) {
- setIsOpen( ! isOpen );
- }
- },
- }
- : {};
-
- // Render the summary once here in the block and once in the fill. The fill can be slotted once elsewhere. The fill is only
- // rendered on small and mobile screens.
+ // Render the summary once here in the block and once in the fill, so the
+ // fill can be slotted elsewhere. Below the large breakpoint both render and
+ // the CSS decides which one is visible.
return (
<>
<div className={ className }>
@@ -59,7 +42,7 @@ const FrontendBlock = ( {
'is-open': isOpen,
}
) }
- { ...orderSummaryProps }
+ { ...toggleProps }
>
<p
className="wc-block-components-checkout-order-summary__title-text"
@@ -96,9 +79,10 @@ const FrontendBlock = ( {
<OrderMetaSlotFill />
</div>
</div>
- { /* Render a second instance of the order summary in a different location for smaller screens
- This prevents the fill from appearing on desktop before width data is available */ }
- { ( isMedium || isSmall || isMobile ) && (
+ { /* Render a second instance of the order summary in a different location for smaller screens.
+ On large containers the CSS hides this fill, so rendering it while the
+ width is still unknown is safe. */ }
+ { ! isLarge && (
<CheckoutOrderSummaryFill>
<div
className={ `${ className } checkout-order-summary-block-fill-wrapper` }
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-block/test/collapsible-bar.js b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-block/test/collapsible-bar.js
new file mode 100644
index 00000000000..15df12fadd9
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-block/test/collapsible-bar.js
@@ -0,0 +1,216 @@
+/**
+ * External dependencies
+ */
+import { render, screen, fireEvent, createEvent } from '@testing-library/react';
+import { SlotFillProvider } from '@woocommerce/blocks-checkout';
+
+/**
+ * Internal dependencies
+ */
+import { previewCart as mockPreviewCart } from '../../../../../previews/cart';
+import SummaryBlock from '../frontend';
+import { CheckoutOrderSummarySlot } from '../slotfills';
+
+const baseContext = jest.requireMock( '@woocommerce/base-context' );
+
+jest.mock( '@woocommerce/settings', () => ( {
+ ...jest.requireActual( '@woocommerce/settings' ),
+ SITE_CURRENCY: {
+ code: 'USD',
+ symbol: '$',
+ thousandSeparator: ',',
+ decimalSeparator: '.',
+ minorUnit: 2,
+ prefix: '$',
+ suffix: '',
+ },
+} ) );
+
+jest.mock( '@woocommerce/base-context/hooks', () => ( {
+ ...jest.requireActual( '@woocommerce/base-context/hooks' ),
+ useStoreCart: jest.fn().mockReturnValue( {
+ cartItems: [],
+ cartTotals: {
+ total_price: '4000',
+ currency_code: 'USD',
+ currency_symbol: '$',
+ currency_minor_unit: 2,
+ currency_decimal_separator: '.',
+ currency_thousand_separator: ',',
+ currency_prefix: '$',
+ currency_suffix: '',
+ },
+ cartCoupons: [],
+ cartFees: [],
+ cartNeedsShipping: false,
+ shippingRates: [],
+ shippingAddress: mockPreviewCart.shipping_address,
+ billingAddress: mockPreviewCart.billing_address,
+ cartHasCalculatedShipping: true,
+ } ),
+} ) );
+
+jest.mock( '@woocommerce/base-context', () => ( {
+ ...jest.requireActual( '@woocommerce/base-context' ),
+ useContainerWidthContext: jest.fn(),
+} ) );
+
+/**
+ * The values ContainerWidthContext produces for a given container class name.
+ * An empty class name is what it reports until the resize observer first fires.
+ *
+ * @param {string} containerClassName Class name for the measured width.
+ * @return {Object} The context value.
+ */
+const containerWidthOf = ( containerClassName ) => ( {
+ hasContainerWidth: containerClassName !== '',
+ containerClassName,
+ isMobile: containerClassName === 'is-mobile',
+ isSmall: containerClassName === 'is-small',
+ isMedium: containerClassName === 'is-medium',
+ isLarge: containerClassName === 'is-large',
+} );
+
+/**
+ * @param {string} containerClassName Class name for the measured width.
+ * @return {import('@testing-library/react').RenderResult} The render result.
+ */
+const renderAt = ( containerClassName ) => {
+ baseContext.useContainerWidthContext.mockReturnValue(
+ containerWidthOf( containerClassName )
+ );
+ return render(
+ <SummaryBlock>
+ <div />
+ </SummaryBlock>
+ );
+};
+
+describe( 'Checkout Order Summary collapsible bar', () => {
+ describe.each( [ '', 'is-mobile', 'is-small', 'is-medium' ] )(
+ 'when the container reports %p',
+ ( containerClassName ) => {
+ it( 'exposes the summary as an expandable button', () => {
+ renderAt( containerClassName );
+
+ const bar = screen.getByRole( 'button', {
+ name: /Order summary/,
+ } );
+ expect( bar ).toHaveAttribute( 'aria-expanded', 'false' );
+ expect( bar ).toHaveAttribute( 'aria-controls' );
+ expect( bar ).toHaveAttribute( 'tabindex', '0' );
+ } );
+
+ it( 'toggles on click', () => {
+ renderAt( containerClassName );
+
+ const bar = screen.getByRole( 'button', {
+ name: /Order summary/,
+ } );
+ fireEvent.click( bar );
+ expect( bar ).toHaveAttribute( 'aria-expanded', 'true' );
+ } );
+
+ it.each( [ 'Enter', ' ' ] )( 'toggles on %p', ( key ) => {
+ renderAt( containerClassName );
+
+ const bar = screen.getByRole( 'button', {
+ name: /Order summary/,
+ } );
+ fireEvent.keyDown( bar, { key } );
+ expect( bar ).toHaveAttribute( 'aria-expanded', 'true' );
+ } );
+
+ // Without this the browser still acts on the key: Space scrolls the
+ // page out from under the summary that just opened, and Enter
+ // submits the checkout form the bar sits inside.
+ it.each( [ 'Enter', ' ' ] )(
+ 'suppresses the browser default for %p',
+ ( key ) => {
+ renderAt( containerClassName );
+
+ const bar = screen.getByRole( 'button', {
+ name: /Order summary/,
+ } );
+ const event = createEvent.keyDown( bar, { key } );
+ fireEvent( bar, event );
+
+ expect( event.defaultPrevented ).toBe( true );
+ }
+ );
+
+ it( 'collapses again when activated a second time', () => {
+ renderAt( containerClassName );
+
+ const bar = screen.getByRole( 'button', {
+ name: /Order summary/,
+ } );
+ fireEvent.click( bar );
+ expect( bar ).toHaveAttribute( 'aria-expanded', 'true' );
+
+ fireEvent.click( bar );
+ expect( bar ).toHaveAttribute( 'aria-expanded', 'false' );
+ } );
+ }
+ );
+
+ it( 'is not a button once the container is large enough for two columns', () => {
+ const { container } = renderAt( 'is-large' );
+
+ const bar = container.querySelector(
+ '.wc-block-components-checkout-order-summary__title'
+ );
+ expect( bar ).not.toHaveAttribute( 'role' );
+ expect( bar ).not.toHaveAttribute( 'tabindex' );
+ expect( bar ).not.toHaveAttribute( 'aria-expanded' );
+ } );
+} );
+
+describe( 'Checkout Order Summary fill', () => {
+ /**
+ * Renders the block together with the slot the fill targets, so the second
+ * summary instance actually appears in the tree.
+ *
+ * @param {string} containerClassName Class name for the measured width.
+ * @return {import('@testing-library/react').RenderResult} The render result.
+ */
+ const renderWithSlot = ( containerClassName ) => {
+ baseContext.useContainerWidthContext.mockReturnValue(
+ containerWidthOf( containerClassName )
+ );
+ return render(
+ <SlotFillProvider>
+ <SummaryBlock>
+ <div />
+ </SummaryBlock>
+ <CheckoutOrderSummarySlot />
+ </SlotFillProvider>
+ );
+ };
+
+ /**
+ * @param {HTMLElement} container Render container.
+ * @return {NodeList} The rendered fill wrappers.
+ */
+ const fillsIn = ( container ) =>
+ container.querySelectorAll(
+ '.checkout-order-summary-block-fill-wrapper'
+ );
+
+ describe.each( [ '', 'is-mobile', 'is-small', 'is-medium' ] )(
+ 'when the container reports %p',
+ ( containerClassName ) => {
+ it( 'renders the second summary into the slot', () => {
+ const { container } = renderWithSlot( containerClassName );
+
+ expect( fillsIn( container ) ).toHaveLength( 1 );
+ } );
+ }
+ );
+
+ it( 'renders no fill once the container is large enough for two columns', () => {
+ const { container } = renderWithSlot( 'is-large' );
+
+ expect( fillsIn( container ) ).toHaveLength( 0 );
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-block/use-order-summary-toggle.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-block/use-order-summary-toggle.ts
new file mode 100644
index 00000000000..e439d3c75d4
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-block/use-order-summary-toggle.ts
@@ -0,0 +1,53 @@
+/**
+ * External dependencies
+ */
+import { useContainerWidthContext } from '@woocommerce/base-context';
+import { useId, useState } from '@wordpress/element';
+import type { HTMLAttributes, KeyboardEvent } from 'react';
+
+export type OrderSummaryToggle = {
+ isOpen: boolean;
+ isLarge: boolean;
+ ariaControlsId: string;
+ toggleProps: HTMLAttributes< HTMLDivElement >;
+};
+
+/**
+ * State and props for the collapsible "Order summary" bar, shared by the
+ * frontend block and the editor preview so the two can't drift apart.
+ *
+ * The interactive props are attached when the container is not large, rather
+ * than by listing the smaller sizes: the width is unknown until the resize
+ * observer first reports, and the CSS has already collapsed the summary by
+ * then. Assuming the collapsed presentation while unsure keeps the bar
+ * operable; the reverse leaves it inert with no other way to see the totals.
+ *
+ * @return {OrderSummaryToggle} Open state, the measured size, the id the bar
+ * controls, and the props to spread on the bar.
+ */
+export const useOrderSummaryToggle = (): OrderSummaryToggle => {
+ const { isLarge } = useContainerWidthContext();
+ const [ isOpen, setIsOpen ] = useState( false );
+ const ariaControlsId = useId();
+
+ const toggleProps = ! isLarge
+ ? {
+ role: 'button',
+ onClick: () => setIsOpen( ! isOpen ),
+ 'aria-expanded': isOpen,
+ 'aria-controls': ariaControlsId,
+ tabIndex: 0,
+ onKeyDown: ( event: KeyboardEvent ) => {
+ if ( event.key === 'Enter' || event.key === ' ' ) {
+ // Space scrolls the page and Enter submits the
+ // surrounding checkout form. A real button would
+ // suppress both for us.
+ event.preventDefault();
+ setIsOpen( ! isOpen );
+ }
+ },
+ }
+ : {};
+
+ return { isOpen, isLarge, ariaControlsId, toggleProps };
+};