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();