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 };
+};