Commit ee6b173a67d for woocommerce
commit ee6b173a67d4e5dfd3d4ef66422dd552d2c69f42
Author: Albert Juhé Lluveras <contact@albertjuhe.com>
Date: Wed Apr 1 21:25:44 2026 +0200
Add to Cart + Options: render 3rd-party product types quantity inputs with steppers (#62893)
* Add to Cart + Options: render 3rd-party product types quantity inputs with steppers
* Add changelog file
* Don't print data-wp-interactive unnecessarily
* Linting
* PHPStan fixes
* Use getters for allowIncrease and allowDecrease
* Strengthen input checks in Add to Cart + Options block
* Make sure we support floats in Add to cart form
* Fix floating point artifacts in quantity stepper with fractional steps
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add store lock
* Use max number of decimals between min, max and step for rounding
---------
Co-authored-by: Tung Du <dinhtungdu@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/fix-62677-add-to-cart-with-options-stepper-in-3rd-party-product-types b/plugins/woocommerce/changelog/fix-62677-add-to-cart-with-options-stepper-in-3rd-party-product-types
new file mode 100644
index 00000000000..03c3793a55b
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-62677-add-to-cart-with-options-stepper-in-3rd-party-product-types
@@ -0,0 +1,4 @@
+Significance: minor
+Type: enhancement
+
+Add to Cart + Options: render 3rd-party product types quantity inputs with steppers
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-form/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-form/frontend.ts
index c5bc28f00d7..40befd96057 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-form/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-form/frontend.ts
@@ -4,13 +4,17 @@
import { store } from '@wordpress/interactivity';
import { HTMLElementEvent } from '@woocommerce/types';
+// Stores are locked to prevent 3PD usage until the API is stable.
+const universalLock =
+ 'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
+
const getInputElementFromEvent = (
event: HTMLElementEvent< HTMLButtonElement >
) => {
const target = event.target as HTMLButtonElement;
const inputElement = target.parentElement?.querySelector(
- '.input-text.qty.text'
+ '.wc-block-components-quantity-selector__input'
) as HTMLInputElement | null | undefined;
return inputElement;
@@ -23,10 +27,10 @@ const getInputData = ( event: HTMLElementEvent< HTMLButtonElement > ) => {
return;
}
- const parsedValue = parseInt( inputElement.value, 10 );
- const parsedMinValue = parseInt( inputElement.min, 10 );
- const parsedMaxValue = parseInt( inputElement.max, 10 );
- const parsedStep = parseInt( inputElement.step, 10 );
+ const parsedValue = parseFloat( inputElement.value );
+ const parsedMinValue = parseFloat( inputElement.min );
+ const parsedMaxValue = parseFloat( inputElement.max );
+ const parsedStep = parseFloat( inputElement.step );
const currentValue = isNaN( parsedValue ) ? 0 : parsedValue;
const minValue = isNaN( parsedMinValue ) ? 1 : parsedMinValue;
@@ -42,6 +46,19 @@ const getInputData = ( event: HTMLElementEvent< HTMLButtonElement > ) => {
};
};
+const roundDecimals = (
+ value: number,
+ min: number,
+ max: number,
+ step: number
+): string => {
+ const stepDecimals = ( step.toString().split( '.' )[ 1 ] || '' ).length;
+ const minDecimals = ( min.toString().split( '.' )[ 1 ] || '' ).length;
+ const maxDecimals = ( max.toString().split( '.' )[ 1 ] || '' ).length;
+ const decimals = Math.max( stepDecimals, minDecimals, maxDecimals );
+ return value.toFixed( decimals );
+};
+
/**
* Manually dispatches a 'change' event on the quantity input element.
*
@@ -60,34 +77,63 @@ const dispatchChangeEvent = ( inputElement: HTMLInputElement ) => {
inputElement.dispatchEvent( event );
};
-store( 'woocommerce/add-to-cart-form', {
- state: {},
- actions: {
- addQuantity: ( event: HTMLElementEvent< HTMLButtonElement > ) => {
- const inputData = getInputData( event );
- if ( ! inputData ) {
- return;
- }
- const { currentValue, maxValue, step, inputElement } = inputData;
- const newValue = currentValue + step;
-
- if ( maxValue === undefined || newValue <= maxValue ) {
- inputElement.value = newValue.toString();
- dispatchChangeEvent( inputElement );
- }
+// Note: this store is also used by the Add to Cart + Options block when
+// rendering third party product types that don't use block template parts.
+store(
+ 'woocommerce/add-to-cart-form',
+ {
+ state: {
+ get allowsIncrease() {
+ return true;
+ },
+ get allowsDecrease() {
+ return true;
+ },
},
- removeQuantity: ( event: HTMLElementEvent< HTMLButtonElement > ) => {
- const inputData = getInputData( event );
- if ( ! inputData ) {
- return;
- }
- const { currentValue, minValue, step, inputElement } = inputData;
- const newValue = currentValue - step;
+ actions: {
+ increaseQuantity: (
+ event: HTMLElementEvent< HTMLButtonElement >
+ ) => {
+ const inputData = getInputData( event );
+ if ( ! inputData ) {
+ return;
+ }
+ const { currentValue, minValue, maxValue, step, inputElement } =
+ inputData;
+ const newValue = currentValue + step;
+
+ if ( maxValue === undefined || newValue <= maxValue ) {
+ inputElement.value = roundDecimals(
+ newValue,
+ minValue,
+ maxValue ?? Infinity,
+ step
+ );
+ dispatchChangeEvent( inputElement );
+ }
+ },
+ decreaseQuantity: (
+ event: HTMLElementEvent< HTMLButtonElement >
+ ) => {
+ const inputData = getInputData( event );
+ if ( ! inputData ) {
+ return;
+ }
+ const { currentValue, minValue, maxValue, step, inputElement } =
+ inputData;
+ const newValue = currentValue - step;
- if ( newValue >= minValue ) {
- inputElement.value = newValue.toString();
- dispatchChangeEvent( inputElement );
- }
+ if ( newValue >= minValue ) {
+ inputElement.value = roundDecimals(
+ newValue,
+ minValue,
+ maxValue ?? Infinity,
+ step
+ );
+ dispatchChangeEvent( inputElement );
+ }
+ },
},
},
-} );
+ { lock: universalLock }
+);
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 8417a7f112a..fff24d30e5f 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
@@ -24,10 +24,13 @@
// templates), the quantity selector will stretch to the height of the container.
:where(.wc-block-add-to-cart-with-options__quantity-selector .wc-block-components-quantity-selector) {
height: 100%;
+
+ :where(input[type="number"].qty) {
+ padding: 0.7rem 0;
+ }
}
-:where(.wc-block-add-to-cart-with-options__quantity-selector),
-:where(.wc-block-add-to-cart-with-options-grouped-product-item-selector) {
+:where(.wc-block-add-to-cart-with-options) {
// This resets some WC component styles, so we need it to have specificity
// until those stylesheets are updated.
.wc-block-components-quantity-selector.wc-block-components-quantity-selector {
@@ -57,7 +60,7 @@
margin: 0;
font-size: 0.8em;
box-sizing: content-box;
- padding: 0.7rem 0;
+ padding: 0.9rem 0;
margin-right: unset;
max-width: 3.631em; /* This value is based on the WC core styles. */
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/plugins/custom-product-type.php b/plugins/woocommerce/client/blocks/tests/e2e/plugins/custom-product-type.php
index 12d331ab060..f86b9d5cdd5 100644
--- a/plugins/woocommerce/client/blocks/tests/e2e/plugins/custom-product-type.php
+++ b/plugins/woocommerce/client/blocks/tests/e2e/plugins/custom-product-type.php
@@ -8,9 +8,56 @@
* @package woocommerce-blocks-test-custom-product-type
*/
-function woocommerce_register_custom_product_type( $product_types ) {
- $product_types[ 'custom-product-type' ] = 'Custom Product Type';
- return $product_types;
-}
+// phpcs:disable Squiz.Commenting.ClassComment.Missing
+// phpcs:disable Squiz.Commenting.FunctionComment.Missing
-add_filter( 'product_type_selector', 'woocommerce_register_custom_product_type' );
+declare(strict_types=1);
+
+add_action(
+ 'init',
+ function () {
+ class WC_Product_Custom extends \WC_Product_Simple {
+ public function get_type() {
+ return 'custom';
+ }
+ }
+ }
+);
+
+add_filter(
+ 'woocommerce_product_class',
+ function ( $classname, $product_type ) {
+ if ( 'custom' === $product_type ) {
+ return WC_Product_Custom::class;
+ }
+ return $classname;
+ },
+ 10,
+ 2
+);
+
+add_action(
+ 'woocommerce_custom_add_to_cart',
+ function () {
+ global $product;
+
+ echo '<form class="cart" action="' . esc_url( $product->get_permalink() ) . '" method="post" enctype="multipart/form-data">';
+ woocommerce_quantity_input(
+ array(
+ 'min_value' => 1,
+ 'max_value' => 10,
+ 'input_value' => 1,
+ )
+ );
+
+ echo '<button type="submit" name="add-to-cart" value="' . esc_attr( $product->get_id() ) . '">Add to cart</button></form>';
+ }
+);
+
+add_filter(
+ 'product_type_selector',
+ function ( $product_types ) {
+ $product_types['custom'] = 'Custom Product Type';
+ return $product_types;
+ }
+);
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/tests/add-to-cart-with-options/add-to-cart-with-options.block_theme.spec.ts b/plugins/woocommerce/client/blocks/tests/e2e/tests/add-to-cart-with-options/add-to-cart-with-options.block_theme.spec.ts
index 56b8cebe9d4..594d13693fd 100644
--- a/plugins/woocommerce/client/blocks/tests/e2e/tests/add-to-cart-with-options/add-to-cart-with-options.block_theme.spec.ts
+++ b/plugins/woocommerce/client/blocks/tests/e2e/tests/add-to-cart-with-options/add-to-cart-with-options.block_theme.spec.ts
@@ -36,7 +36,8 @@ const test = base.extend< {
} );
test.describe( 'Add to Cart + Options Block', () => {
- test( 'allows switching to 3rd-party product types', async ( {
+ test( 'allows adding 3rd-party product types to cart when using PHP templates', async ( {
+ page,
pageObject,
editor,
requestUtils,
@@ -44,15 +45,44 @@ test.describe( 'Add to Cart + Options Block', () => {
await requestUtils.activatePlugin(
'woocommerce-blocks-test-custom-product-type'
);
+ const cliOutput = await wpCLI(
+ `wc product create --slug="custom-product" --name="Custom Product" --type="custom" --regular_price=10 --user=1`
+ );
+ const customProductId = cliOutput.stdout.match( /\d+/g )?.pop();
- await pageObject.updateSingleProductTemplate();
- await pageObject.switchProductType( 'Custom Product Type' );
+ await test.step( 'allows switching to 3rd-party product types in the editor', async () => {
+ await pageObject.updateSingleProductTemplate();
+ await pageObject.switchProductType( 'Custom Product Type' );
- const block = editor.canvas.getByLabel(
- `Block: ${ pageObject.BLOCK_NAME }`
- );
- const skeleton = block.locator( '.wc-block-components-skeleton' );
- await expect( skeleton ).toBeVisible();
+ const block = editor.canvas.getByLabel(
+ `Block: ${ pageObject.BLOCK_NAME }`
+ );
+ const skeleton = block.locator( '.wc-block-components-skeleton' );
+ await expect( skeleton ).toBeVisible();
+
+ await editor.saveSiteEditorEntities( {
+ isOnlyCurrentEntityDirty: true,
+ } );
+ } );
+
+ await test.step( 'allows interacting with the form in the frontend', async () => {
+ await page.goto( `/?p=${ customProductId }` );
+ const quantityInput = page.getByLabel( 'Product quantity' );
+
+ await expect( quantityInput ).toHaveValue( '1' );
+ await page
+ .getByLabel( 'Increase quantity of Custom Product' )
+ .click();
+
+ await expect( quantityInput ).toHaveValue( '2' );
+ await page.getByRole( 'button', { name: 'Add to cart' } ).click();
+
+ await expect(
+ page
+ .getByRole( 'alert' )
+ .getByText( /have been added to your cart/i )
+ ).toBeVisible();
+ } );
} );
test( 'allows adding simple products to cart', async ( {
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartForm.php b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartForm.php
index 1895b6ce896..b15b68c2b93 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartForm.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartForm.php
@@ -83,7 +83,7 @@ class AddToCartForm extends AbstractBlock {
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.addQuantity" class="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--plus">+</button>';
+ 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>';
},
$product_html ?? ''
);
@@ -92,7 +92,7 @@ class AddToCartForm extends AbstractBlock {
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.removeQuantity" class="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--minus">−</button>';
+ 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>';
},
$new_html ?? ''
);
@@ -107,19 +107,26 @@ class AddToCartForm extends AbstractBlock {
* @return string The Add to Cart form HTML with classes added.
*/
private function add_stepper_classes_to_add_to_cart_form_input( $product_html ) {
- $html = new \WP_HTML_Tag_Processor( $product_html );
-
- // Add classes to the form.
- while ( $html->next_tag( array( 'class_name' => 'quantity' ) ) ) {
- $html->add_class( 'wc-block-components-quantity-selector' );
- }
-
- $html = new \WP_HTML_Tag_Processor( $html->get_updated_html() );
- while ( $html->next_tag( array( 'class_name' => 'input-text' ) ) ) {
- $html->add_class( 'wc-block-components-quantity-selector__input' );
+ $processor = new \WP_HTML_Tag_Processor( $product_html );
+
+ while ( $processor->next_tag() ) {
+ if (
+ $processor->get_tag() === 'DIV' &&
+ $processor->has_class( 'quantity' )
+ ) {
+ $processor->add_class( 'wc-block-components-quantity-selector' );
+ }
+
+ if (
+ $processor->get_tag() === 'INPUT' &&
+ $processor->get_attribute( 'name' ) === 'quantity' &&
+ $processor->get_attribute( 'type' ) !== 'hidden'
+ ) {
+ $processor->add_class( 'wc-block-components-quantity-selector__input' );
+ }
}
- return $html->get_updated_html();
+ return $processor->get_updated_html();
}
/**
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/AddToCartWithOptions.php b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/AddToCartWithOptions.php
index 90ea3960bac..384796b9de9 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/AddToCartWithOptions.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/AddToCartWithOptions.php
@@ -644,7 +644,21 @@ class AddToCartWithOptions extends AbstractBlock {
);
$form_html = ob_get_clean();
- $form_html = sprintf( '<div %1$s>%2$s</div>', get_block_wrapper_attributes( $wrapper_attributes ), $form_html );
+
+ $has_visible_quantity_input = $form_html ? Utils::has_visible_quantity_input( $form_html ) : false;
+ if ( $has_visible_quantity_input ) {
+ $product_name = $product->get_name();
+ $form_html = Utils::add_quantity_steppers( $form_html, $product_name );
+ $form_html = Utils::add_quantity_stepper_classes( $form_html );
+ $wrapper_attributes['data-wp-interactive'] = 'woocommerce/add-to-cart-form';
+ wp_enqueue_script_module( 'woocommerce/add-to-cart-form' );
+ }
+
+ $form_html = sprintf(
+ '<div %1$s>%2$s</div>',
+ get_block_wrapper_attributes( $wrapper_attributes ),
+ $form_html
+ );
}
$product = $previous_product;
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/Utils.php b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/Utils.php
index 117a06a16bf..aa22b0f5305 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/Utils.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/Utils.php
@@ -74,13 +74,21 @@ class Utils {
public static function add_quantity_stepper_classes( $quantity_html ) {
$processor = new \WP_HTML_Tag_Processor( $quantity_html );
- // Add classes to the form.
- while ( $processor->next_tag( array( 'class_name' => 'quantity' ) ) ) {
- $processor->add_class( 'wc-block-components-quantity-selector' );
- }
+ while ( $processor->next_tag() ) {
+ if (
+ $processor->get_tag() === 'DIV' &&
+ $processor->has_class( 'quantity' )
+ ) {
+ $processor->add_class( 'wc-block-components-quantity-selector' );
+ }
- while ( $processor->next_tag( array( 'class_name' => 'input-text' ) ) ) {
- $processor->add_class( 'wc-block-components-quantity-selector__input' );
+ if (
+ $processor->get_tag() === 'INPUT' &&
+ $processor->get_attribute( 'name' ) === 'quantity' &&
+ $processor->get_attribute( 'type' ) !== 'hidden'
+ ) {
+ $processor->add_class( 'wc-block-components-quantity-selector__input' );
+ }
}
return $processor->get_updated_html();