next_tag() ) { if ( $processor->get_tag() === 'INPUT' && $processor->has_class( 'qty' ) && $processor->get_attribute( 'type' ) !== 'hidden' ) { return true; } } return false; } /** * Add increment and decrement buttons to the quantity input field. * * @param string $quantity_html Quantity input HTML. * @param string $product_name Product name. * @return string Quantity input HTML with increment and decrement buttons. */ public static function add_quantity_steppers( $quantity_html, $product_name ) { // Regex pattern to match the element with id starting with 'quantity_'. $pattern = '/(]*id="quantity_[^"]*"[^>]*\/>)/'; // Add the minus button BEFORE the matched 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. */ $minus_aria = esc_attr( sprintf( __( 'Reduce quantity of %s', 'woocommerce' ), $product_name ) ); return '' . $matches[1]; }, $quantity_html ?? '' ); // Add the plus button AFTER the matched element. $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] . ''; }, $new_html ?? '' ); return $new_html ?? ''; } /** * Add classes to the Quantity Selector needed for the stepper style. * * @param string $quantity_html The Quantity Selector HTML. * * @return string The Quantity Selector HTML with classes added. */ public static function add_quantity_stepper_classes( $quantity_html ) { $processor = new \WP_HTML_Tag_Processor( $quantity_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->has_class( 'qty' ) && $processor->get_attribute( 'type' ) !== 'hidden' ) { $processor->add_class( 'wc-block-components-quantity-selector__input' ); } } return $processor->get_updated_html(); } /** * 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 array $context { * Optional context for quantity input. * @type int $productId Product ID for context-specific behavior. * @type bool $allowZero Whether to allow zero quantity. * } * @param bool $set_product_context Whether to set a local woocommerce/products context on the wrapper. * Only needed when the quantity input belongs to a different product than * the one provided by the inherited context (e.g. child items in grouped products). * Setting this unnecessarily shadows the parent context and prevents * variationId updates from propagating. * * @return string The quantity HTML with interactive wrapper. */ public static function make_quantity_input_interactive( $quantity_html, $wrapper_attributes = array(), $input_attributes = array(), $context = array(), $set_product_context = false ) { $processor = new \WP_HTML_Tag_Processor( $quantity_html ); global $product; if ( $set_product_context && $product instanceof \WC_Product ) { $product_context = array( 'productId' => $product->get_id(), 'variationId' => null, ); $products_context = 'woocommerce/products::' . wp_json_encode( $product_context, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ); // This moves the `woocommerce/products` context to a nested `div`, // as multiple context directives are not supported in the same // element in WordPress 6.8. Once WooCommerce drops support for // WordPress 6.8, this code can be refactored. if ( $processor->next_tag( array( 'tag_name' => 'div', 'class_name' => 'quantity', ) ) ) { $processor->set_attribute( 'data-wp-context', $products_context ); } else { // If filtered markup omits the `div.quantity`, reinitialize the // processor so the input bindings below still execute. $processor = new \WP_HTML_Tag_Processor( $quantity_html ); } } if ( $processor->next_tag( 'input' ) && $processor->get_attribute( 'type' ) === 'number' && strpos( $processor->get_attribute( 'name' ), 'quantity' ) !== false ) { $default_quantity = $product instanceof \WC_Product ? $product->get_min_purchase_quantity() : 1; $input_quantity = isset( $context['allowZero'] ) && true === $context['allowZero'] ? 0 : $default_quantity; wp_interactivity_state( 'woocommerce/add-to-cart-with-options-quantity-selector', array( 'inputQuantity' => $input_quantity, ) ); $processor->set_attribute( 'data-wp-on--blur', 'actions.handleQuantityBlur' ); $processor->set_attribute( 'data-wp-bind--value', 'state.inputQuantity' ); foreach ( $input_attributes as $attribute => $value ) { $processor->set_attribute( $attribute, $value ); } } $quantity_html = $processor->get_updated_html(); $wrapper_attributes = array_merge( array( 'data-wp-interactive' => 'woocommerce/add-to-cart-with-options-quantity-selector', 'data-wp-init' => 'callbacks.storeInputElementRef', ), $wrapper_attributes ); $context_attribute = wp_interactivity_data_wp_context( $context ); return sprintf( '