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 “ 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' );
+ }
}