Commit 5bf73053042 for woocommerce

commit 5bf730530427c73f96b2fe99d8f0a871c71f5bef
Author: Noruzzaman <noruzzamanrubel@gmail.com>
Date:   Thu Oct 22 23:45:50 2026 +0600

    fix: aria-label on Product Button (#70064)

    Co-authored-by: frosso <francesco.rosso@automattic.com>
    Co-authored-by: Francesco <frosso@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/fix-69976-product-button-aria-label b/plugins/woocommerce/changelog/fix-69976-product-button-aria-label
new file mode 100644
index 00000000000..0237a2fecea
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-69976-product-button-aria-label
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Make the Add to Cart button's screen reader label match its "N in cart" text once a product is in the cart.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/button/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/button/frontend.ts
index 42fc432adaf..07b1b7bf6f0 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/button/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/button/frontend.ts
@@ -28,6 +28,8 @@ interface Context {
 	animationStatus: AnimationStatus;
 	hasPressedButton: boolean;
 	inTheCartText: string;
+	addToCartAriaLabel: string;
+	inTheCartAriaLabel: string;
 }

 enum AnimationStatus {
@@ -138,6 +140,15 @@ const productButtonStore = {

 			return addToCartText;
 		},
+		get addToCartAriaLabel(): string {
+			const { addToCartText, addToCartAriaLabel, inTheCartAriaLabel } =
+				getContext< Context >();
+			const text = state.addToCartText;
+
+			return text === addToCartText
+				? addToCartAriaLabel
+				: inTheCartAriaLabel.replace( '###', text );
+		},
 		get displayViewCart(): boolean {
 			const { displayViewCart } = getContext< Context >();
 			if ( ! displayViewCart ) {
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php
index b3aeaa06aae..d0159273158 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php
@@ -183,19 +183,35 @@ class ProductButton extends AbstractBlock {
 			)
 		);

+		$get_add_to_cart_text = function () {
+			$context          = wp_interactivity_get_context();
+			$quantity         = $context['tempQuantity'];
+			$add_to_cart_text = $context['addToCartText'];
+
+			return $quantity > 0 ? sprintf(
+				/* translators: %s: product number. */
+				__( '%s in cart', 'woocommerce' ),
+				$quantity
+			) : $add_to_cart_text;
+		};
+
 		wp_interactivity_state(
 			'woocommerce/product-button',
 			array(
-				'addToCartText' => function () {
+				'addToCartText'      => $get_add_to_cart_text,
+				'addToCartAriaLabel' => function () use ( $get_add_to_cart_text ) {
 					$context = wp_interactivity_get_context();
-					$quantity = $context['tempQuantity'];
-					$add_to_cart_text = $context['addToCartText'];
-
-					return $quantity > 0 ? sprintf(
-						/* translators: %s: product number. */
-						__( '%s in cart', 'woocommerce' ),
-						$quantity
-					) : $add_to_cart_text;
+					$text    = $get_add_to_cart_text();
+
+					if ( $text === $context['addToCartText'] ) {
+						return $context['addToCartAriaLabel'];
+					}
+
+					$pos = strpos( $context['inTheCartAriaLabel'], '###' );
+
+					return false !== $pos
+						? substr_replace( $context['inTheCartAriaLabel'], $text, $pos, 3 )
+						: $context['inTheCartAriaLabel'];
 				},
 			)
 		);
@@ -224,8 +240,22 @@ class ProductButton extends AbstractBlock {
 			$product
 		);

+		// The accessible name must keep containing the visible text once it changes to "N in cart" (WCAG 2.5.3).
 		if ( isset( $args['attributes']['aria-label'] ) ) {
 			$args['attributes']['aria-label'] = wp_strip_all_tags( $args['attributes']['aria-label'] );
+
+			if ( empty( $args['attributes']['aria-label'] ) ) {
+				unset( $args['attributes']['aria-label'] );
+			} else {
+				// Decode so the browser doesn't read entities such as &ldquo; as literal text when it sets the label.
+				$context['addToCartAriaLabel'] = html_entity_decode( $args['attributes']['aria-label'], ENT_QUOTES, get_bloginfo( 'charset' ) );
+				$context['inTheCartAriaLabel'] = sprintf(
+					/* translators: 1: Button text, such as "2 in cart". 2: Button description, such as "Add to cart: “Beanie”". */
+					_x( '%1$s, %2$s', 'Product button screen reader label: 1: button text, 2: product description', 'woocommerce' ),
+					'###',
+					$context['addToCartAriaLabel']
+				);
+			}
 		}

 		$div_directives = '
@@ -241,6 +271,10 @@ class ProductButton extends AbstractBlock {
 			'data-wp-on--click="actions.addCartItem"';
 		$anchor_directive  = $is_descendant_of_add_to_cart_form ? '' : 'data-wp-on--click="woocommerce/product-collection::actions.viewProduct"';

+		if ( isset( $context['addToCartAriaLabel'] ) ) {
+			$button_directives .= ' data-wp-bind--aria-label="state.addToCartAriaLabel"';
+		}
+
 		$span_button_directives = '
 			data-wp-text="state.addToCartText"
 			data-wp-class--wc-block-slide-in="state.slideInAnimation"
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/add-to-cart-with-options/add-to-cart-with-options.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/add-to-cart-with-options/add-to-cart-with-options.block_theme.spec.ts
index 7f6aed7a160..11135cf583a 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/add-to-cart-with-options/add-to-cart-with-options.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/add-to-cart-with-options/add-to-cart-with-options.block_theme.spec.ts
@@ -118,6 +118,10 @@ test.describe( 'Add to Cart + Options Block', () => {
 		await addToCartButton.click();

 		await expect( addToCartButton ).toHaveText( '3 in cart' );
+		await expect( addToCartButton ).toHaveAttribute(
+			'aria-label',
+			'3 in cart, Add to cart: “Beanie”'
+		);

 		await page.getByLabel( 'Product quantity' ).fill( '1' );
 		await addToCartButton.click();
@@ -1537,8 +1541,7 @@ test.describe( 'Add to Cart + Options Block', () => {
 		} );

 		for ( const optionStyle of [ 'chips', 'dropdown' ] as (
-			| 'chips'
-			| 'dropdown'
+			'chips' | 'dropdown'
 		)[] ) {
 			// eslint-disable-next-line playwright/expect-expect
 			test( `${ optionStyle }: Test the autoselect block attribute`, async ( {
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/product-button/product-button.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/product-button/product-button.block_theme.spec.ts
index c1d36e06f0e..adc8b7ad409 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/product-button/product-button.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/product-button/product-button.block_theme.spec.ts
@@ -53,6 +53,10 @@ test.describe( `${ blockData.name } Block`, () => {
 		await block.click();
 		await expect( block.locator( 'loading' ) ).toBeHidden();
 		await expect( block.getByRole( 'button' ) ).toHaveText( '1 in cart' );
+		await expect( block.getByRole( 'button' ) ).toHaveAttribute(
+			'aria-label',
+			`1 in cart, Add to cart: “${ productName }”`
+		);
 		await expect( block.getByRole( 'link' ) ).toHaveText( 'View cart' );

 		await frontendUtils.goToCheckout();
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductButton.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductButton.php
index 870ea066977..67255b8b034 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductButton.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductButton.php
@@ -31,18 +31,27 @@ class ProductButton extends \WP_UnitTestCase {
 	}

 	/**
-	 * Restore test options.
+	 * Restore test options and remove the published `woocommerce` state, which the base class does not reset.
 	 */
 	protected function tearDown(): void {
-		foreach ( $this->previous_options as $option_name => $value ) {
-			if ( null === $value ) {
-				delete_option( $option_name );
-			} else {
-				update_option( $option_name, $value );
+		try {
+			foreach ( $this->previous_options as $option_name => $value ) {
+				if ( null === $value ) {
+					delete_option( $option_name );
+				} else {
+					update_option( $option_name, $value );
+				}
 			}
-		}

-		parent::tearDown();
+			$interactivity = wp_interactivity();
+			$property      = new \ReflectionProperty( $interactivity, 'state_data' );
+			$property->setAccessible( true );
+			$state_data = $property->getValue( $interactivity );
+			unset( $state_data['woocommerce'] );
+			$property->setValue( $interactivity, $state_data );
+		} finally {
+			parent::tearDown();
+		}
 	}

 	/**
@@ -53,7 +62,7 @@ class ProductButton extends \WP_UnitTestCase {
 	 */
 	private function render_product_button( \WC_Product $product ): string {
 		return do_blocks(
-			'<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><!-- wp:woocommerce/product-button /--><!-- /wp:woocommerce/single-product -->'
+			'<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><div class="wp-block-woocommerce-single-product"><!-- wp:woocommerce/product-button /--></div><!-- /wp:woocommerce/single-product -->'
 		);
 	}

@@ -110,4 +119,95 @@ class ProductButton extends \WP_UnitTestCase {

 		$this->assertStringContainsString( 'rel="nofollow noopener noreferrer"', $markup );
 	}
+
+	/**
+	 * @testdox AJAX button aria-label starts with the visible button text when $quantity are in the cart.
+	 * @testWith [0, "Add to cart: “Beanie”"]
+	 *           [2, "2 in cart, Add to cart: “Beanie”"]
+	 *
+	 * @param int    $quantity Quantity of the product in the cart.
+	 * @param string $expected Expected aria-label.
+	 */
+	public function test_ajax_button_aria_label_starts_with_button_text( int $quantity, string $expected ): void {
+		update_option( 'woocommerce_enable_ajax_add_to_cart', 'yes' );
+		$product = WC_Helper_Product::create_simple_product( true, array( 'name' => 'Beanie' ) );
+		wp_interactivity_state(
+			'woocommerce',
+			array(
+				'cart' => array(
+					'items' => array(
+						array(
+							'id'       => $product->get_id(),
+							'quantity' => $quantity,
+						),
+					),
+				),
+			)
+		);
+
+		$aria_label = $this->get_button_aria_label( $this->render_product_button( $product ) );
+
+		$this->assertSame( $expected, $aria_label, 'The aria-label should start with the text shown on the button' );
+	}
+
+	/**
+	 * @testdox AJAX button aria-label replaces only the first placeholder when product name contains placeholder characters.
+	 */
+	public function test_ajax_button_aria_label_replaces_only_first_placeholder(): void {
+		update_option( 'woocommerce_enable_ajax_add_to_cart', 'yes' );
+		$product = WC_Helper_Product::create_simple_product( true, array( 'name' => 'Box ### Special' ) );
+		wp_interactivity_state(
+			'woocommerce',
+			array(
+				'cart' => array(
+					'items' => array(
+						array(
+							'id'       => $product->get_id(),
+							'quantity' => 1,
+						),
+					),
+				),
+			)
+		);
+
+		$aria_label = $this->get_button_aria_label( $this->render_product_button( $product ) );
+
+		$this->assertSame( '1 in cart, Add to cart: “Box ### Special”', $aria_label );
+	}
+
+	/**
+	 * @testdox Labels that become empty after sanitization are omitted.
+	 */
+	public function test_product_button_omits_aria_label_when_sanitization_results_in_empty(): void {
+		update_option( 'woocommerce_enable_ajax_add_to_cart', 'yes' );
+		$product = WC_Helper_Product::create_simple_product();
+		$filter  = static function ( array $args ): array {
+			$args['attributes']['aria-label'] = '<em></em>';
+			return $args;
+		};
+
+		add_filter( 'woocommerce_loop_add_to_cart_args', $filter );
+		try {
+			$markup = $this->render_product_button( $product );
+		} finally {
+			remove_filter( 'woocommerce_loop_add_to_cart_args', $filter );
+		}
+
+		$processor = new \WP_HTML_Tag_Processor( $markup );
+		$this->assertTrue( $processor->next_tag( 'button' ), 'Button element should exist in rendered markup.' );
+		$this->assertNull( $processor->get_attribute( 'aria-label' ), 'Empty aria-label attribute should be omitted.' );
+	}
+
+	/**
+	 * Get the aria-label of the rendered button.
+	 *
+	 * @param string $markup Rendered block markup.
+	 * @return string|null The aria-label, decoded.
+	 */
+	private function get_button_aria_label( string $markup ): ?string {
+		$processor = new \WP_HTML_Tag_Processor( $markup );
+		$processor->next_tag( 'button' );
+
+		return $processor->get_attribute( 'aria-label' );
+	}
 }