Commit 563fcdb8a8 for woocommerce

commit 563fcdb8a877d797c23ca434dd4a507b9c3c4e13
Author: Kathy <507025+helgatheviking@users.noreply.github.com>
Date:   Tue Nov 18 00:23:54 2025 -0500

    Eliminate hard-coded 'grouped' product handling in frontend of quantity selector block (#61496)

    ---------

    Co-authored-by: Albert Juhé Lluveras <contact@albertjuhe.com>

diff --git a/plugins/woocommerce/changelog/issues-61495-quantity-input-extended b/plugins/woocommerce/changelog/issues-61495-quantity-input-extended
new file mode 100644
index 0000000000..a595a21be8
--- /dev/null
+++ b/plugins/woocommerce/changelog/issues-61495-quantity-input-extended
@@ -0,0 +1,4 @@
+Significance: minor
+Type: enhancement
+
+Eliminate hard-coded 'grouped' product handling in frontend of quantity selector block
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/quantity-selector/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/quantity-selector/frontend.ts
index 828473f620..53bd7c0911 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/quantity-selector/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/quantity-selector/frontend.ts
@@ -13,6 +13,7 @@ import type { AddToCartWithOptionsStore } from '../frontend';

 export type Context = {
 	productId: number;
+	allowZero?: boolean;
 };

 // Stores are locked to prevent 3PD usage until the API is stable.
@@ -83,24 +84,28 @@ store< QuantitySelectorStore >(
 				// Note: in grouped products, `productData` will be the parent product.
 				// We handle grouped products decrease differently because we
 				// allow setting the quantity to 0.
-				const { productData, quantity } =
+				const { quantity, selectedAttributes } =
 					addToCartWithOptionsStore.state;

-				if ( ! productData ) {
-					return true;
-				}
+				const { allowZero, productId } = getContext< Context >();

-				if ( productData.type === 'grouped' ) {
-					const { productId } = getContext< Context >();
+				const productObject = getProductData(
+					productId,
+					selectedAttributes
+				);

-					return quantity[ productId ] > 0;
+				if ( ! productObject ) {
+					return true;
 				}

-				const { id, min, step } = productData;
+				const { id, min, step } = productObject;

 				const currentQuantity = quantity[ id ] || 0;

-				return currentQuantity - step >= min;
+				return (
+					( allowZero && currentQuantity > 0 ) ||
+					currentQuantity - step >= min
+				);
 			},
 			get allowsIncrease() {
 				const { quantity, selectedAttributes } =
@@ -146,6 +151,7 @@ store< QuantitySelectorStore >(
 				);

 				let newValue = currentValue + 1;
+
 				if ( productObject ) {
 					const { max, min, step } = productObject;
 					newValue = currentValue + step;
@@ -170,40 +176,23 @@ store< QuantitySelectorStore >(
 				}

 				const currentValue = Number( inputElement.value ) || 0;
+				const { allowZero, productId } = getContext< Context >();
+				const { selectedAttributes } = addToCartWithOptionsStore.state;

-				const { productId } = getContext< Context >();
-				const { productData, selectedAttributes } =
-					addToCartWithOptionsStore.state;
-
-				const parentProductObject = productData;
-
-				let productObject = parentProductObject;
-
-				if ( parentProductObject?.type === 'grouped' ) {
-					productObject = getProductData(
-						productId,
-						selectedAttributes
-					);
-				}
+				const productObject = getProductData(
+					productId,
+					selectedAttributes
+				);

 				let newValue = currentValue - 1;

 				if ( productObject ) {
-					const { min, step } = productObject;
+					const { max, min, step } = productObject;
 					newValue = currentValue - step;
-
-					if ( newValue < min ) {
-						// In grouped product children, we allow decreasing the value
-						// down to 0, even if the minimum value is greater than 0.
-						if ( parentProductObject?.type === 'grouped' ) {
-							if ( currentValue > min ) {
-								newValue = min;
-							} else {
-								newValue = 0;
-							}
-						} else {
-							newValue = min;
-						}
+					if ( allowZero && newValue < min && currentValue === min ) {
+						newValue = 0;
+					} else {
+						newValue = Math.min( max, Math.max( min, newValue ) );
 					}
 				}

@@ -223,26 +212,31 @@ store< QuantitySelectorStore >(
 			handleQuantityBlur: (
 				event: HTMLElementEvent< HTMLInputElement >
 			) => {
-				const { productData, selectedAttributes } =
-					addToCartWithOptionsStore.state;
-				let min = 1;
+				const { allowZero, productId } = getContext< Context >();
+				const { selectedAttributes } = addToCartWithOptionsStore.state;

-				if ( ! productData ) {
+				const productObject = getProductData(
+					productId,
+					selectedAttributes
+				);
+
+				if ( ! productObject ) {
 					return;
 				}

-				const { productId } = getContext< Context >();
+				const { min } = productObject;

 				// In grouped products, we reset invalid inputs to ''.
 				if (
+					allowZero &&
 					( Number.isNaN( event.target.valueAsNumber ) ||
-						event.target.valueAsNumber === 0 ) &&
-					productData.type === 'grouped'
+						event.target.valueAsNumber === 0 )
 				) {
 					addToCartWithOptionsStore.actions.setQuantity(
 						productId,
 						0
 					);
+
 					if ( Number.isNaN( event.target.valueAsNumber ) ) {
 						event.target.value = '';
 					}
@@ -250,24 +244,15 @@ store< QuantitySelectorStore >(
 					return;
 				}

-				const childProductData =
-					productData.type === 'grouped'
-						? getProductData( productId, selectedAttributes )
-						: productData;
-
-				if ( ! childProductData ) {
-					return;
-				}
-
-				min = childProductData.min;
-
-				// In other product types, we reset inputs to `min` if they are
-				// 0 or NaN.
-				const newValue =
+				let newValue;
+				if (
 					Number.isFinite( event.target.valueAsNumber ) &&
 					event.target.valueAsNumber > 0
-						? event.target.valueAsNumber
-						: min;
+				) {
+					newValue = event.target.valueAsNumber;
+				} else {
+					newValue = allowZero ? 0 : min;
+				}

 				addToCartWithOptionsStore.actions.setQuantity(
 					productId,
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/GroupedProductItemSelector.php b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/GroupedProductItemSelector.php
index ecd288be9b..a48e825763 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/GroupedProductItemSelector.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/GroupedProductItemSelector.php
@@ -82,8 +82,13 @@ class GroupedProductItemSelector extends AbstractBlock {
 		$quantity_html = AddToCartWithOptionsUtils::add_quantity_steppers( $quantity_html, $product_name );
 		$quantity_html = AddToCartWithOptionsUtils::add_quantity_stepper_classes( $quantity_html );

+		$context = array(
+			'productId' => $product->get_id(),
+			'allowZero' => true, // The item is optional in grouped products.
+		);
+
 		// Add interactive data attribute for the stepper functionality.
-		$quantity_html = AddToCartWithOptionsUtils::make_quantity_input_interactive( $quantity_html, array(), array(), $product->get_id() );
+		$quantity_html = AddToCartWithOptionsUtils::make_quantity_input_interactive( $quantity_html, array(), array(), $context );

 		return $quantity_html;
 	}
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/Utils.php b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/Utils.php
index be403028d8..fac7dbb238 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/Utils.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/Utils.php
@@ -77,14 +77,18 @@ class Utils {
 	/**
 	 * Make the quantity input interactive by wrapping it with the necessary data attribute and adding a blur event listener.
 	 *
-	 * @param string   $quantity_html The quantity HTML.
-	 * @param array    $wrapper_attributes Optional wrapper attributes.
-	 * @param array    $input_attributes Optional input attributes.
-	 * @param int|null $child_product_id Optional child product ID.
+	 * @param string $quantity_html The quantity HTML.
+	 * @param array  $wrapper_attributes Optional wrapper attributes.
+	 * @param array  $input_attributes Optional input attributes.
+	 * @param array  $context {
+	 *     Optional context for quantity input.
+	 *     @type int  $productId  Product ID for context-specific behavior.
+	 *     @type bool $allowZero  Whether to allow zero quantity.
+	 * }
 	 *
 	 * @return string The quantity HTML with interactive wrapper.
 	 */
-	public static function make_quantity_input_interactive( $quantity_html, $wrapper_attributes = array(), $input_attributes = array(), $child_product_id = null ) {
+	public static function make_quantity_input_interactive( $quantity_html, $wrapper_attributes = array(), $input_attributes = array(), $context = array() ) {
 		$processor = new \WP_HTML_Tag_Processor( $quantity_html );
 		if (
 			$processor->next_tag( 'input' ) &&
@@ -110,10 +114,11 @@ class Utils {
 		global $product;

 		$context_attribute = wp_interactivity_data_wp_context(
-			array(
-				'productId' => $child_product_id || ! $product instanceof \WC_Product ?
-					$child_product_id :
-					$product->get_id(),
+			wp_parse_args(
+				$context,
+				array(
+					'productId' => $product instanceof \WC_Product ? $product->get_id() : 0,
+				)
 			)
 		);