Commit 8a12c3deaf2 for woocommerce

commit 8a12c3deaf2580eed2b308bdf4b03d19bedefafd
Author: Oleksandr Aratovskyi <79862886+oaratovskyi@users.noreply.github.com>
Date:   Mon Jul 20 18:32:43 2026 +0300

    Fix quantity stepper focus and reading order to match visual order (#66670)

    * fix: reorder cart quantity stepper DOM to − input + for logical focus order

    Refs #60718

    * fix: reorder Add to Cart + Options quantity stepper DOM to − input +

    Refs #60718

    * fix: reorder non-blockified Add to Cart form quantity stepper DOM to − input +

    Refs #60718

    * fix: drop quantity stepper CSS order overrides so DOM order drives layout

    Refs #60718

    * chore: add changelog for quantity stepper DOM order fix

    Refs #60718

    * fix: reorder storefront Mini-Cart quantity stepper DOM to − input +

    The Interactivity-API Mini-Cart (MiniCartProductsTableBlock) still
    emitted the old input, minus, plus DOM order relying on CSS flexbox
    `order` overrides. Those overrides were removed for the other four
    quantity-stepper surfaces earlier on this branch, so once assets
    rebuild the Mini-Cart stepper would visually regress to `input − +`
    and keep the mismatched keyboard focus / screen-reader order (WCAG
    1.3.2/2.4.3).

    Reorder the minus button before the input, matching the other four
    surfaces already fixed. Add a DOM-order guard test that renders the
    block via the real render_block() pipeline and asserts minus precedes
    input precedes plus, mirroring the existing AddToCartWithOptions and
    AddToCartForm guard tests.

    Refs #60718

    * chore: update quantity stepper changelog to mention Mini-Cart

    The changelog entry only mentioned the cart and add-to-cart steppers,
    but the fix now also covers the storefront Mini-Cart stepper.

    Refs #60718

    * Reduce quantity stepper DOM-order tests to injector guards

    The PR added four tests asserting stepper element order. Two check the
    order of literal hand-written markup (the React QuantitySelector and the
    Mini-Cart template), where a reorder is intentional and eyeball-visible,
    so those tests only detect deliberate changes.

    Remove those two. Keep the AddToCartForm and AddToCartWithOptions tests,
    which guard the two-pass preg_replace_callback injectors — algorithmic
    HTML manipulation where an order regression is plausible and not visible
    in a diff.

diff --git a/plugins/woocommerce/changelog/fix-quantity-stepper-dom-focus-order b/plugins/woocommerce/changelog/fix-quantity-stepper-dom-focus-order
new file mode 100644
index 00000000000..9e679af863f
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-quantity-stepper-dom-focus-order
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Reorder the cart, mini-cart, and add-to-cart quantity steppers so the decrease button precedes the quantity input in the DOM, matching the visual order for a logical keyboard focus and screen-reader reading sequence.
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/quantity-selector/index.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/quantity-selector/index.tsx
index 991e9a55515..55c6b84eda7 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/quantity-selector/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/quantity-selector/index.tsx
@@ -202,6 +202,32 @@ const QuantitySelector = ( {

 	return (
 		<div className={ classes }>
+			{ editable && (
+				<button
+					ref={ decreaseButtonRef }
+					aria-label={ sprintf(
+						/* translators: %s refers to the item name in the cart. */
+						__( 'Reduce quantity of %s', 'woocommerce' ),
+						itemName
+					) }
+					className="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--minus"
+					disabled={ ! canDecrease }
+					onClick={ () => {
+						const newQuantity = internalState - step;
+						commitValue( newQuantity );
+						expectedQuantityTypeRef.current = 'decrease';
+						speak(
+							sprintf(
+								/* translators: %s refers to the item's new quantity in the cart. */
+								__( 'Quantity reduced to %s.', 'woocommerce' ),
+								newQuantity
+							)
+						);
+					} }
+				>
+					&#8722;
+				</button>
+			) }
 			<input
 				ref={ inputRef }
 				className="wc-block-components-quantity-selector__input"
@@ -222,62 +248,33 @@ const QuantitySelector = ( {
 				) }
 			/>
 			{ editable && (
-				<>
-					<button
-						ref={ decreaseButtonRef }
-						aria-label={ sprintf(
-							/* translators: %s refers to the item name in the cart. */
-							__( 'Reduce quantity of %s', 'woocommerce' ),
-							itemName
-						) }
-						className="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--minus"
-						disabled={ ! canDecrease }
-						onClick={ () => {
-							const newQuantity = internalState - step;
-							commitValue( newQuantity );
-							expectedQuantityTypeRef.current = 'decrease';
-							speak(
-								sprintf(
-									/* translators: %s refers to the item's new quantity in the cart. */
-									__(
-										'Quantity reduced to %s.',
-										'woocommerce'
-									),
-									newQuantity
-								)
-							);
-						} }
-					>
-						&#8722;
-					</button>
-					<button
-						ref={ increaseButtonRef }
-						aria-label={ sprintf(
-							/* translators: %s refers to the item's name in the cart. */
-							__( 'Increase quantity of %s', 'woocommerce' ),
-							itemName
-						) }
-						disabled={ ! canIncrease }
-						className="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--plus"
-						onClick={ () => {
-							const newQuantity = internalState + step;
-							commitValue( newQuantity );
-							expectedQuantityTypeRef.current = 'increase';
-							speak(
-								sprintf(
-									/* translators: %s refers to the item's new quantity in the cart. */
-									__(
-										'Quantity increased to %s.',
-										'woocommerce'
-									),
-									newQuantity
-								)
-							);
-						} }
-					>
-						+
-					</button>
-				</>
+				<button
+					ref={ increaseButtonRef }
+					aria-label={ sprintf(
+						/* translators: %s refers to the item's name in the cart. */
+						__( 'Increase quantity of %s', 'woocommerce' ),
+						itemName
+					) }
+					disabled={ ! canIncrease }
+					className="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--plus"
+					onClick={ () => {
+						const newQuantity = internalState + step;
+						commitValue( newQuantity );
+						expectedQuantityTypeRef.current = 'increase';
+						speak(
+							sprintf(
+								/* translators: %s refers to the item's new quantity in the cart. */
+								__(
+									'Quantity increased to %s.',
+									'woocommerce'
+								),
+								newQuantity
+							)
+						);
+					} }
+				>
+					+
+				</button>
 			) }
 		</div>
 	);
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/quantity-selector/style.scss b/plugins/woocommerce/client/blocks/assets/js/base/components/quantity-selector/style.scss
index 726ae349b0f..31cd4aab61f 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/quantity-selector/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/quantity-selector/style.scss
@@ -31,7 +31,6 @@
 		line-height: 1;
 		margin: 0;
 		min-width: 40px;
-		order: 2;
 		text-align: center;
 		vertical-align: middle;

@@ -74,12 +73,10 @@

 	> .wc-block-components-quantity-selector__button--minus {
 		border-radius: $universal-border-radius 0 0 $universal-border-radius;
-		order: 1;
 	}

 	> .wc-block-components-quantity-selector__button--plus {
 		border-radius: 0 $universal-border-radius $universal-border-radius 0;
-		order: 3;
 	}
 	&[hidden] {
 		display: none;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-form/style.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-form/style.scss
index 5986884594f..a3161499d80 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-form/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-form/style.scss
@@ -118,7 +118,6 @@ div.wc-block-add-to-cart-form.wc-block-add-to-cart-form--stepper {
 			-moz-appearance: textfield;
 			border: unset;
 			text-align: center;
-			order: 2;
 			margin: 0;
 			/**
 			* This is a base style for the input text element in WooCommerce that prevents inputs from appearing too small.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/components/quantity-stepper/index.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/components/quantity-stepper/index.tsx
index c2fccc8df76..f6dca91976a 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/components/quantity-stepper/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/components/quantity-stepper/index.tsx
@@ -1,15 +1,15 @@
 const QuantityStepper = () => {
 	return (
 		<div className="quantity wc-block-components-quantity-selector">
+			<button className="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--minus">
+				−
+			</button>
 			<input
 				type="number"
 				value="1"
 				className="input-text qty text"
 				readOnly
 			/>
-			<button className="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--minus">
-				−
-			</button>
 			<button className="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--plus">
 				+
 			</button>
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/style.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/style.scss
index 2c5818c5994..529e294794f 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/style.scss
@@ -44,7 +44,6 @@
 			-moz-appearance: textfield;
 			border: unset;
 			text-align: center;
-			order: 2;
 			font-weight: 600;
 			background-color: transparent;
 			color: currentColor;
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartForm.php b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartForm.php
index b7fb729455b..32b65589c50 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartForm.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartForm.php
@@ -75,23 +75,25 @@ class AddToCartForm extends AbstractBlock {
 	private function add_steppers( $product_html, $product_name ) {
 		// Regex pattern to match the <input> element with id starting with 'quantity_'.
 		$pattern = '/(<input[^>]*id="quantity_[^"]*"[^>]*\/>)/';
-		// Replacement string to add button AFTER the matched <input> element.
+		// Add the minus button BEFORE the matched <input> element so DOM order matches the
+		// visual order (− input +), giving a logical focus and reading sequence.
 		// Use preg_replace_callback to avoid backreference interpretation of $, \ sequences in product names.
 		$new_html = preg_replace_callback(
 			$pattern,
 			function ( $matches ) use ( $product_name ) {
 				/* translators: %s refers to the item name in the cart. */
-				$plus_aria = esc_attr( sprintf( __( 'Increase quantity of %s', 'woocommerce' ), $product_name ) );
-				return $matches[1] . '<button aria-label="' . $plus_aria . '" type="button" data-wp-on--click="actions.increaseQuantity" class="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--plus">+</button>';
+				$minus_aria = esc_attr( sprintf( __( 'Reduce quantity of %s', 'woocommerce' ), $product_name ) );
+				return '<button aria-label="' . $minus_aria . '" type="button" data-wp-on--click="actions.decreaseQuantity" class="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--minus">−</button>' . $matches[1];
 			},
 			$product_html ?? ''
 		);
+		// Add the plus button AFTER the matched <input> element.
 		$new_html = preg_replace_callback(
 			$pattern,
 			function ( $matches ) use ( $product_name ) {
 				/* translators: %s refers to the item name in the cart. */
-				$minus_aria = esc_attr( sprintf( __( 'Reduce quantity of %s', 'woocommerce' ), $product_name ) );
-				return $matches[1] . '<button aria-label="' . $minus_aria . '" type="button" data-wp-on--click="actions.decreaseQuantity" class="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--minus">−</button>';
+				$plus_aria = esc_attr( sprintf( __( 'Increase quantity of %s', 'woocommerce' ), $product_name ) );
+				return $matches[1] . '<button aria-label="' . $plus_aria . '" type="button" data-wp-on--click="actions.increaseQuantity" class="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--plus">+</button>';
 			},
 			$new_html ?? ''
 		);
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/Utils.php b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/Utils.php
index 38c3b0f5f3b..24289c911cd 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/Utils.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/Utils.php
@@ -41,23 +41,25 @@ class Utils {
 	public static function add_quantity_steppers( $quantity_html, $product_name ) {
 		// Regex pattern to match the <input> element with id starting with 'quantity_'.
 		$pattern = '/(<input[^>]*id="quantity_[^"]*"[^>]*\/>)/';
-		// Replacement string to add button AFTER the matched <input> element.
+		// Add the minus button BEFORE the matched <input> element so DOM order matches the
+		// visual order (− input +), giving a logical focus and reading sequence.
 		// Use preg_replace_callback to avoid backreference interpretation of $, \ sequences in product names.
 		$new_html = preg_replace_callback(
 			$pattern,
 			function ( $matches ) use ( $product_name ) {
 				/* translators: %s refers to the item name in the cart. */
-				$plus_aria = esc_attr( sprintf( __( 'Increase quantity of %s', 'woocommerce' ), $product_name ) );
-				return $matches[1] . '<button aria-label="' . $plus_aria . '" type="button" data-wp-on--click="actions.increaseQuantity" data-wp-bind--disabled="!state.allowsIncrease" class="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--plus">+</button>';
+				$minus_aria = esc_attr( sprintf( __( 'Reduce quantity of %s', 'woocommerce' ), $product_name ) );
+				return '<button aria-label="' . $minus_aria . '" type="button" data-wp-on--click="actions.decreaseQuantity" data-wp-bind--disabled="!state.allowsDecrease" class="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--minus">−</button>' . $matches[1];
 			},
 			$quantity_html ?? ''
 		);
+		// Add the plus button AFTER the matched <input> element.
 		$new_html = preg_replace_callback(
 			$pattern,
 			function ( $matches ) use ( $product_name ) {
 				/* translators: %s refers to the item name in the cart. */
-				$minus_aria = esc_attr( sprintf( __( 'Reduce quantity of %s', 'woocommerce' ), $product_name ) );
-				return $matches[1] . '<button aria-label="' . $minus_aria . '" type="button" data-wp-on--click="actions.decreaseQuantity" data-wp-bind--disabled="!state.allowsDecrease" class="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--minus">−</button>';
+				$plus_aria = esc_attr( sprintf( __( 'Increase quantity of %s', 'woocommerce' ), $product_name ) );
+				return $matches[1] . '<button aria-label="' . $plus_aria . '" type="button" data-wp-on--click="actions.increaseQuantity" data-wp-bind--disabled="!state.allowsIncrease" class="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--plus">+</button>';
 			},
 			$new_html ?? ''
 		);
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/MiniCartProductsTableBlock.php b/plugins/woocommerce/src/Blocks/BlockTypes/MiniCartProductsTableBlock.php
index d8c5928597c..8cc14f9d31b 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/MiniCartProductsTableBlock.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/MiniCartProductsTableBlock.php
@@ -172,6 +172,15 @@ class MiniCartProductsTableBlock extends AbstractInnerBlock {
 									</div>
 									<div class="wc-block-cart-item__quantity">
 										<div class="wc-block-components-quantity-selector" data-wp-bind--hidden="state.cartItem.sold_individually">
+											<button
+												data-wp-bind--disabled="state.minimumReached"
+												data-wp-on--click="actions.decrementQuantity"
+												data-wp-bind--aria-label="state.reduceQuantityLabel"
+												data-wp-bind--hidden="!state.cartItem.quantity_limits.editable"
+												class="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--minus"
+											>
+												−
+											</button>
 											<input
 												data-wp-on--input="actions.overrideInvalidQuantity"
 												data-wp-on--change="actions.changeQuantity"
@@ -184,15 +193,6 @@ class MiniCartProductsTableBlock extends AbstractInnerBlock {
 												type="number"
 												step="1"
 											>
-											<button
-												data-wp-bind--disabled="state.minimumReached"
-												data-wp-on--click="actions.decrementQuantity"
-												data-wp-bind--aria-label="state.reduceQuantityLabel"
-												data-wp-bind--hidden="!state.cartItem.quantity_limits.editable"
-												class="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--minus"
-											>
-												−
-											</button>
 											<button
 												data-wp-bind--disabled="state.maximumReached"
 												data-wp-on--click="actions.incrementQuantity"
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToCartForm.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToCartForm.php
index 208c7265fd3..b5541e1f43e 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToCartForm.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToCartForm.php
@@ -45,4 +45,39 @@ class AddToCartForm extends WC_Unit_Test_Case {
 		$this->assertStringContainsString( 'wc-block-components-quantity-selector__input', $result, 'The input should receive the stepper input class.' );
 		$this->assertStringContainsString( 'custom_name', $result, 'The original input name value should be preserved.' );
 	}
+
+	/**
+	 * Tests that add_steppers injects the buttons in visual DOM order (− input +),
+	 * so keyboard focus and screen-reader reading order are logical.
+	 *
+	 * @covers \Automattic\WooCommerce\Blocks\BlockTypes\AddToCartForm::add_steppers
+	 */
+	public function test_add_steppers_injects_buttons_in_visual_dom_order(): void {
+		$quantity_html = '<div class="quantity"><input type="number" id="quantity_123" class="input-text qty text" name="quantity" value="1" /></div>';
+
+		$block = new class(
+			Package::container()->get( Api::class ),
+			Package::container()->get( AssetDataRegistry::class ),
+			new IntegrationRegistry()
+		) extends \Automattic\WooCommerce\Blocks\BlockTypes\AddToCartForm {
+			/**
+			 * Skip block registration; woocommerce/add-to-cart-form is already registered when WooCommerce loads.
+			 */
+			protected function initialize() {
+			}
+		};
+
+		$reflection = new \ReflectionClass( $block );
+		$method     = $reflection->getMethod( 'add_steppers' );
+		$method->setAccessible( true );
+
+		$result = $method->invoke( $block, $quantity_html, 'Test Product' );
+
+		// The minus button must precede the input, which must precede the plus button.
+		$this->assertMatchesRegularExpression(
+			'/quantity-selector__button--minus.*id="quantity_.*quantity-selector__button--plus/s',
+			$result,
+			'add_steppers should inject buttons in − input + DOM order.'
+		);
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToCartWithOptions.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToCartWithOptions.php
index 1199a2ea9a2..4431313411b 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToCartWithOptions.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToCartWithOptions.php
@@ -470,6 +470,27 @@ class AddToCartWithOptions extends \WP_UnitTestCase {
 		);
 	}

+	/**
+	 * Tests that the stepper buttons render in visual DOM order (− input +),
+	 * so keyboard focus and screen-reader reading order are logical.
+	 */
+	public function test_stepper_buttons_render_in_visual_dom_order() {
+		$simple_product = new \WC_Product_Simple();
+		$simple_product->set_regular_price( 10 );
+		$simple_product->set_manage_stock( true );
+		$simple_product->set_stock_quantity( 10 );
+		$product_id = $simple_product->save();
+
+		$markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product_id . '} --><!-- wp:woocommerce/add-to-cart-with-options /--><!-- /wp:woocommerce/single-product -->' );
+
+		// The minus button must precede the quantity input, which must precede the plus button.
+		$this->assertMatchesRegularExpression(
+			'/quantity-selector__button--minus.*id="quantity_.*quantity-selector__button--plus/s',
+			$markup,
+			'Stepper buttons should render in − input + DOM order.'
+		);
+	}
+
 	/**
 	 * Tests that the quantity selector and its steppers are hidden when
 	 * a filter sets min and max quantity to the same value for a product.