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
+ )
+ );
+ } }
+ >
+ −
+ </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
- )
- );
- } }
- >
- −
- </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.