Commit 9e086fb230d for woocommerce
commit 9e086fb230d4441de3c6a5e14b3aa862f55ce16f
Author: Tung Du <dinhtungdu@gmail.com>
Date: Wed Oct 14 19:12:06 2026 +0700
Update sale badges for the selected variation (#69596)
* Update sale badges for the selected variation
* Refactor sale badge discount calculation into a shared helper
Co-authored-by: Cursor <cursoragent@cursor.com>
* Move sale badge interactivity into its own store
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify sale badge interactivity markup and remove weak frontend test
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix sale badge CI lint and PHPUnit attribute-order assertions
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix empty sale badge alignment class
* Simplify sale badge alignment expression
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
diff --git a/plugins/woocommerce/changelog/selected-variation-sale-badge b/plugins/woocommerce/changelog/selected-variation-sale-badge
new file mode 100644
index 00000000000..238afc63e54
--- /dev/null
+++ b/plugins/woocommerce/changelog/selected-variation-sale-badge
@@ -0,0 +1,4 @@
+Significance: patch
+Type: enhancement
+
+Update sale badges to show the discount for the selected product variation.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/README.md b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/README.md
index 75f7aaf0732..7c83f646b57 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/README.md
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/README.md
@@ -4,7 +4,7 @@ Product element blocks show parts of a product, such as its image, title, price,
## Structure
-Each element has its own directory with block metadata and the code needed for editing, saving, and rendering it. Shared editor code lives in `shared/`. The root `index.js` imports the element registration files, while `component-init.js` registers components that are loaded when needed. `frontend.ts` contains the shared product element callback for interactive updates.
+Each element has its own directory with block metadata and the code needed for editing, saving, and rendering it. Shared editor code lives in `shared/`. The root `index.js` imports the element registration files, while `component-init.js` registers components that are loaded when needed. `frontend.ts` contains the shared product element callback for interactive HTML field updates. Blocks with their own Interactivity logic, such as the button and sale badge, keep a dedicated `frontend.ts` in their directory.
Shared block registration and rendering helpers live in dedicated files under `assets/js/utils/` because they are also used outside this directory. Import elements through `@woocommerce/product-elements` and helpers from their `@woocommerce/utils/<file>` paths.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/block.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/block.tsx
index 9674dde77b4..933c31e5964 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/block.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/block.tsx
@@ -21,6 +21,7 @@ import type { HTMLAttributes, ReactElement } from 'react';
*/
import './style.scss';
import type { BlockAttributes } from './types';
+import { getSaleBadgeDiscount } from './utils';
type Props = BlockAttributes &
HTMLAttributes< HTMLDivElement > & {
@@ -51,12 +52,12 @@ export const Block = ( props: Props ): ReactElement | null => {
let label = props.saleText || __( 'Sale', 'woocommerce' );
if ( isNumeric && product.type !== 'grouped' ) {
const prices = 'prices' in product ? product.prices : undefined;
- const regular = Number( prices?.regular_price );
- const price = Number( prices?.price );
- if ( regular > 0 && price < regular ) {
- const discount = regular - price;
- const percentage = Math.round( ( discount / regular ) * 100 );
- if ( props.badgeContent === 'percentage' && percentage === 0 ) {
+ const discount = getSaleBadgeDiscount( prices );
+ if ( discount ) {
+ if (
+ props.badgeContent === 'percentage' &&
+ discount.percentage === 0
+ ) {
return null;
}
const value =
@@ -64,10 +65,10 @@ export const Block = ( props: Props ): ReactElement | null => {
? sprintf(
/* translators: %s: discount percentage. %% is the percent sign. */
__( '%s%%', 'woocommerce' ),
- percentage
+ discount.percentage
)
: formatPrice(
- discount,
+ discount.amount,
getCurrencyFromPriceResponse( prices )
);
// Parent prices are independent minima, so the editor preview may differ from the largest variation discount.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/frontend.ts
new file mode 100644
index 00000000000..ea08f7e3bb0
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/frontend.ts
@@ -0,0 +1,109 @@
+/**
+ * External dependencies
+ */
+import { store, getContext, getConfig } from '@wordpress/interactivity';
+import '@woocommerce/stores/woocommerce/products';
+import type { ProductsStore } from '@woocommerce/stores/woocommerce/products';
+import { formatPriceWithCurrency } from '@woocommerce/price-format/utils/currency';
+
+/**
+ * Internal dependencies
+ */
+import { getSaleBadgeDiscount } from './utils';
+
+// 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 { state: productsState } = store< ProductsStore >(
+ 'woocommerce/products',
+ {},
+ { lock: universalLock }
+);
+
+type SaleBadgeConfig = {
+ saleBadgePercentageTemplate: string;
+ saleBadgeScreenReaderTemplate: string;
+};
+
+type SaleBadgeContext = {
+ saleBadgeText: string;
+ saleBadgeScreenReaderText: string;
+ badgeContent: string;
+ prefix: string;
+ suffix: string;
+};
+
+const { state } = store(
+ 'woocommerce/product-sale-badge',
+ {
+ state: {
+ get saleBadgeText(): string | null {
+ const variation = productsState.productVariationInContext;
+ const context = getContext< SaleBadgeContext >();
+ if ( ! variation ) {
+ return context.saleBadgeText;
+ }
+ if ( ! variation.on_sale ) {
+ return null;
+ }
+ if (
+ ! [ 'amount', 'percentage' ].includes(
+ context.badgeContent
+ )
+ ) {
+ return context.saleBadgeText;
+ }
+ const prices = variation.prices;
+ const discount = getSaleBadgeDiscount( prices );
+ if ( ! discount ) {
+ return context.saleBadgeText;
+ }
+ if (
+ context.badgeContent === 'percentage' &&
+ discount.percentage === 0
+ ) {
+ return null;
+ }
+ const config = getConfig(
+ 'woocommerce/product-sale-badge'
+ ) as SaleBadgeConfig;
+ let value: string;
+ if ( context.badgeContent === 'percentage' ) {
+ value = config.saleBadgePercentageTemplate.replace(
+ '%s',
+ String( discount.percentage )
+ );
+ } else {
+ value = formatPriceWithCurrency( discount.amount, {
+ code: prices.currency_code,
+ symbol: prices.currency_symbol,
+ minorUnit: prices.currency_minor_unit,
+ decimalSeparator: prices.currency_decimal_separator,
+ thousandSeparator: prices.currency_thousand_separator,
+ prefix: prices.currency_prefix,
+ suffix: prices.currency_suffix,
+ } );
+ }
+ return `${ context.prefix }${ value }${ context.suffix }`;
+ },
+ get saleBadgeScreenReaderText(): string {
+ const config = getConfig(
+ 'woocommerce/product-sale-badge'
+ ) as SaleBadgeConfig;
+ if ( ! productsState.productVariationInContext ) {
+ return getContext< SaleBadgeContext >()
+ .saleBadgeScreenReaderText;
+ }
+ return config.saleBadgeScreenReaderTemplate.replace(
+ '%s',
+ () => state.saleBadgeText ?? ''
+ );
+ },
+ get isSaleBadgeHidden(): boolean {
+ return state.saleBadgeText === null;
+ },
+ },
+ },
+ { lock: true }
+);
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/style.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/style.scss
index 463c08243ff..d378fd41606 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/style.scss
@@ -1,6 +1,10 @@
.wp-block-woocommerce-product-sale-badge {
display: flex;
flex-direction: column;
+
+ &[hidden] {
+ display: none;
+ }
}
.wc-block-components-product-sale-badge {
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/utils.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/utils.ts
new file mode 100644
index 00000000000..f56de0f3bc7
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/utils.ts
@@ -0,0 +1,20 @@
+type SaleBadgePrices = {
+ price?: string;
+ regular_price?: string;
+};
+
+/**
+ * Returns the discount in the currency's minor unit and as a rounded percentage, or null when the price is not discounted.
+ *
+ * Shared by the editor preview and the frontend script module, so it must not import packages that script modules cannot load.
+ */
+export const getSaleBadgeDiscount = (
+ prices: SaleBadgePrices | undefined
+): { amount: number; percentage: number } | null => {
+ const regular = Number( prices?.regular_price );
+ const amount = regular - Number( prices?.price );
+ if ( ! ( regular > 0 && amount > 0 ) ) {
+ return null;
+ }
+ return { amount, percentage: Math.round( ( amount / regular ) * 100 ) };
+};
diff --git a/plugins/woocommerce/client/blocks/bin/webpack-config-interactive-blocks.js b/plugins/woocommerce/client/blocks/bin/webpack-config-interactive-blocks.js
index e7abe4d8a20..2e5b4ce4bc2 100644
--- a/plugins/woocommerce/client/blocks/bin/webpack-config-interactive-blocks.js
+++ b/plugins/woocommerce/client/blocks/bin/webpack-config-interactive-blocks.js
@@ -43,6 +43,10 @@ const entries = {
// Product elements frontend module. Share by several blocks.
'woocommerce/product-elements':
'./assets/js/blocks/product-elements-blocks/frontend.ts',
+ // Sale badge frontend module. Built separately so collection badges can
+ // skip loading variation-only label logic.
+ 'woocommerce/product-sale-badge':
+ './assets/js/blocks/product-elements-blocks/sale-badge/frontend.ts',
// Add to cart with options quantity selector frontend module used by the
// Product Quantity block and the Grouped Product Selector block.
'woocommerce/add-to-cart-with-options-quantity-selector':
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 755c7780768..69bf75dc2f0 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -51513,18 +51513,6 @@ parameters:
count: 1
path: src/Blocks/BlockTypes/ProductSKU.php
- -
- message: '#^@param Automattic\\WooCommerce\\Blocks\\BlockTypes\\WC_Product \$product does not accept actual type of parameter\: WC_Product\.$#'
- identifier: parameter.phpDocType
- count: 1
- path: src/Blocks/BlockTypes/ProductSaleBadge.php
-
- -
- message: '#^Access to property \$context on an unknown class Automattic\\WooCommerce\\Blocks\\BlockTypes\\WP_Block\.$#'
- identifier: class.notFound
- count: 2
- path: src/Blocks/BlockTypes/ProductSaleBadge.php
-
-
message: '#^Method Automattic\\WooCommerce\\Blocks\\BlockTypes\\ProductSaleBadge\:\:get_block_type_uses_context\(\) has no return type specified\.$#'
identifier: missingType.return
@@ -51543,12 +51531,6 @@ parameters:
count: 2
path: src/Blocks/BlockTypes/ProductSaleBadge.php
- -
- message: '#^Parameter \$block of method Automattic\\WooCommerce\\Blocks\\BlockTypes\\ProductSaleBadge\:\:render\(\) has invalid type Automattic\\WooCommerce\\Blocks\\BlockTypes\\WP_Block\.$#'
- identifier: class.notFound
- count: 1
- path: src/Blocks/BlockTypes/ProductSaleBadge.php
-
-
message: '#^Method Automattic\\WooCommerce\\Blocks\\Assets\\Api\:\:add_inline_script\(\) invoked with 3 parameters, 2 required\.$#'
identifier: arguments.count
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductSaleBadge.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductSaleBadge.php
index ddc88d90710..efa6bd9e660 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductSaleBadge.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductSaleBadge.php
@@ -36,15 +36,15 @@ class ProductSaleBadge extends AbstractBlock {
* Register the context.
*/
protected function get_block_type_uses_context() {
- return [ 'query', 'queryId', 'postId' ];
+ return array( 'query', 'queryId', 'postId' );
}
/**
* Include and render the block.
*
- * @param array $attributes Block attributes. Default empty array.
- * @param string $content Block content. Default empty string.
- * @param WP_Block $block Block instance.
+ * @param array $attributes Block attributes. Default empty array.
+ * @param string $content Block content. Default empty string.
+ * @param \WP_Block $block Block instance.
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
@@ -65,8 +65,6 @@ class ProductSaleBadge extends AbstractBlock {
$classname = StyleAttributesUtils::get_classes_by_attributes( $attributes, array( 'extra_classes' ) );
- $align = isset( $attributes['align'] ) ? $attributes['align'] : '';
-
$sale_text = $attributes['saleText'] ?? '';
$sale_text = '' === $sale_text ? __( 'Sale', 'woocommerce' ) : $sale_text;
$badge_content = $attributes['badgeContent'] ?? 'text';
@@ -128,18 +126,95 @@ class ProductSaleBadge extends AbstractBlock {
* @since 10.0.0
*
* @param string $sale_text The sale badge text.
- * @param WC_Product $product The product object.
+ * @param \WC_Product $product The product object.
* @return string The filtered sale badge text.
*/
$sale_text = apply_filters( 'woocommerce_sale_badge_text', $sale_text, $product );
- $output = '<div class="wp-block-woocommerce-product-sale-badge ' . esc_attr( $classname ) . '">';
- $output .= sprintf( '<div class="wc-block-components-product-sale-badge %1$s wc-block-components-product-sale-badge--align-%2$s" style="%3$s">', esc_attr( $classes_and_styles['classes'] ), esc_attr( $align ), esc_attr( $classes_and_styles['styles'] ) );
- $output .= '<span class="wc-block-components-product-sale-badge__text" aria-hidden="true">' . esc_html( $sale_text ) . '</span>';
/* translators: %s: sale badge text. */
$screen_reader_text = sprintf( __( 'Product on sale: %s', 'woocommerce' ), $sale_text );
+ $is_interactive = $product instanceof \WC_Product_Variable
+ && ! isset( $block->context['query']['isProductCollectionBlock'] )
+ && ! isset( $block->context['isDescendantOfGroupedProductSelector'] );
+
+ $wrapper_attributes = array();
+ if ( '' !== $classname ) {
+ $wrapper_attributes['class'] = $classname;
+ }
+ $context_directive = '';
+ $label_attributes = array(
+ 'class' => 'wc-block-components-product-sale-badge__text',
+ 'aria-hidden' => 'true',
+ );
+ $screen_reader_attributes = array(
+ 'class' => 'screen-reader-text',
+ );
+
+ if ( $is_interactive ) {
+ wp_enqueue_script_module( 'woocommerce/product-sale-badge' );
+ wp_interactivity_config(
+ 'woocommerce/product-sale-badge',
+ array(
+ /* translators: %s: discount percentage. %% is the percent sign. */
+ 'saleBadgePercentageTemplate' => sprintf( __( '%s%%', 'woocommerce' ), '%s' ),
+ /* translators: %s: sale badge text. */
+ 'saleBadgeScreenReaderTemplate' => sprintf( __( 'Product on sale: %s', 'woocommerce' ), '%s' ),
+ )
+ );
+ wp_interactivity_state(
+ 'woocommerce/product-sale-badge',
+ array(
+ 'saleBadgeText' => static function () {
+ return wp_interactivity_get_context()['saleBadgeText'];
+ },
+ 'saleBadgeScreenReaderText' => static function () {
+ return wp_interactivity_get_context()['saleBadgeScreenReaderText'];
+ },
+ 'isSaleBadgeHidden' => false,
+ )
+ );
+ $wrapper_attributes['data-wp-interactive'] = 'woocommerce/product-sale-badge';
+ $wrapper_attributes['data-wp-bind--hidden'] = 'state.isSaleBadgeHidden';
+ $wrapper_attributes['aria-live'] = 'polite';
+ $wrapper_attributes['aria-atomic'] = 'true';
+ $context_directive = wp_interactivity_data_wp_context(
+ array(
+ 'saleBadgeText' => $sale_text,
+ 'saleBadgeScreenReaderText' => $screen_reader_text,
+ 'badgeContent' => $attributes['badgeContent'] ?? 'text',
+ 'prefix' => $attributes['prefix'] ?? '',
+ 'suffix' => $attributes['suffix'] ?? '',
+ )
+ );
+ $label_attributes['data-wp-text'] = 'state.saleBadgeText';
+ $screen_reader_attributes['data-wp-text'] = 'state.saleBadgeScreenReaderText';
+ }
- $output .= '<span class="screen-reader-text">' . esc_html( $screen_reader_text ) . '</span>';
+ $alignment_class = isset( $attributes['align'] ) && '' !== $attributes['align']
+ ? ' wc-block-components-product-sale-badge--align-' . esc_attr( $attributes['align'] )
+ : '';
+
+ $output = sprintf(
+ '<div %1$s %2$s>',
+ get_block_wrapper_attributes( $wrapper_attributes ),
+ $context_directive
+ );
+ $output .= sprintf(
+ '<div class="wc-block-components-product-sale-badge %1$s%2$s" style="%3$s">',
+ esc_attr( $classes_and_styles['classes'] ),
+ $alignment_class,
+ esc_attr( $classes_and_styles['styles'] )
+ );
+ $output .= sprintf(
+ '<span %1$s>%2$s</span>',
+ wc_implode_html_attributes( $label_attributes ),
+ esc_html( $sale_text )
+ );
+ $output .= sprintf(
+ '<span %1$s>%2$s</span>',
+ wc_implode_html_attributes( $screen_reader_attributes ),
+ esc_html( $screen_reader_text )
+ );
$output .= '</div></div>';
return $output;
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductSaleBadge.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductSaleBadge.php
index 988332dc616..5ae476a4131 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductSaleBadge.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductSaleBadge.php
@@ -24,7 +24,7 @@ class ProductSaleBadge extends \WP_UnitTestCase {
$product->set_regular_price( '10' );
$product_id = $product->save();
- $markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product_id . '} --><!-- wp:woocommerce/product-sale-badge /--><!-- /wp:woocommerce/single-product -->' );
+ $markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product_id . '} --><div class="wp-block-woocommerce-single-product woocommerce"><!-- wp:woocommerce/product-sale-badge /--></div><!-- /wp:woocommerce/single-product -->' );
$this->assertStringNotContainsString( 'wp-block-woocommerce-product-sale-badge', $markup, 'The outer Sale Badge block should be omitted.' );
$this->assertStringNotContainsString( 'wc-block-components-product-sale-badge', $markup, 'The Sale Badge component should be omitted.' );
@@ -61,10 +61,14 @@ class ProductSaleBadge extends \WP_UnitTestCase {
$product->set_sale_price( '5' );
$product_id = $product->save();
- $markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product_id . '} --><!-- wp:woocommerce/product-sale-badge {"align":"' . $align . '"} /--><!-- /wp:woocommerce/single-product -->' );
+ $markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product_id . '} --><div class="wp-block-woocommerce-single-product woocommerce"><!-- wp:woocommerce/product-sale-badge {"align":"' . $align . '"} /--></div><!-- /wp:woocommerce/single-product -->' );
$expected_class = 'wc-block-components-product-sale-badge--align-' . $align;
- $this->assertStringContainsString( $expected_class, $markup );
+ if ( '' === $align ) {
+ $this->assertStringNotContainsString( 'wc-block-components-product-sale-badge--align-', $markup );
+ } else {
+ $this->assertStringContainsString( $expected_class, $markup );
+ }
foreach ( array_diff( array( 'left', 'center', 'right' ), array( $align ) ) as $other_align ) {
$this->assertStringNotContainsString(
'wc-block-components-product-sale-badge--align-' . $other_align,
@@ -97,10 +101,9 @@ class ProductSaleBadge extends \WP_UnitTestCase {
$product->set_sale_price( '15' );
$product->save();
- $markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><!-- wp:woocommerce/product-sale-badge ' . wp_json_encode( (object) $attributes ) . ' /--><!-- /wp:woocommerce/single-product -->' );
+ $markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><div class="wp-block-woocommerce-single-product woocommerce"><!-- wp:woocommerce/product-sale-badge ' . wp_json_encode( (object) $attributes ) . ' /--></div><!-- /wp:woocommerce/single-product -->' );
- $this->assertStringContainsString( 'wc-block-components-product-sale-badge__text" aria-hidden="true">' . $expected . '</span>', $markup );
- $this->assertStringContainsString( 'screen-reader-text">Product on sale: ' . $expected . '</span>', $markup );
+ $this->assert_sale_badge_labels( $markup, $expected );
}
/**
@@ -149,12 +152,12 @@ class ProductSaleBadge extends \WP_UnitTestCase {
$priced->set_sale_price( $sale );
$priced->save();
- $markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><!-- wp:woocommerce/product-sale-badge {"badgeContent":"' . $mode . '"} /--><!-- /wp:woocommerce/single-product -->' );
+ $markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><div class="wp-block-woocommerce-single-product woocommerce"><!-- wp:woocommerce/product-sale-badge {"badgeContent":"' . $mode . '"} /--></div><!-- /wp:woocommerce/single-product -->' );
if ( '' === $expected ) {
$this->assertStringNotContainsString( 'wc-block-components-product-sale-badge', $markup, 'Zero-rounded percentage badges should be omitted.' );
} else {
- $this->assertStringContainsString( 'aria-hidden="true">' . $expected . '</span>', $markup );
+ $this->assert_sale_badge_labels( $markup, $expected );
}
}
@@ -200,9 +203,8 @@ class ProductSaleBadge extends \WP_UnitTestCase {
'prefix' => 'Save ',
'suffix' => ' off',
);
- $markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><!-- wp:woocommerce/product-sale-badge ' . wp_json_encode( $attributes ) . ' /--><!-- /wp:woocommerce/single-product -->' );
- $this->assertStringContainsString( 'wc-block-components-product-sale-badge__text" aria-hidden="true">' . $expected . '</span>', $markup );
- $this->assertStringContainsString( 'screen-reader-text">Product on sale: ' . $expected . '</span>', $markup );
+ $markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><div class="wp-block-woocommerce-single-product woocommerce"><!-- wp:woocommerce/product-sale-badge ' . wp_json_encode( $attributes ) . ' /--></div><!-- /wp:woocommerce/single-product -->' );
+ $this->assert_sale_badge_labels( $markup, $expected );
}
}
@@ -226,9 +228,8 @@ class ProductSaleBadge extends \WP_UnitTestCase {
'prefix' => 'Save ',
'suffix' => ' off',
);
- $markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><!-- wp:woocommerce/product-sale-badge ' . wp_json_encode( $attributes ) . ' /--><!-- /wp:woocommerce/single-product -->' );
- $this->assertStringContainsString( 'aria-hidden="true">' . $expected . '</span>', $markup );
- $this->assertStringContainsString( 'screen-reader-text">Product on sale: ' . $expected . '</span>', $markup );
+ $markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><div class="wp-block-woocommerce-single-product woocommerce"><!-- wp:woocommerce/product-sale-badge ' . wp_json_encode( $attributes ) . ' /--></div><!-- /wp:woocommerce/single-product -->' );
+ $this->assert_sale_badge_labels( $markup, $expected );
}
/**
@@ -285,8 +286,8 @@ class ProductSaleBadge extends \WP_UnitTestCase {
$variable->get_id() => 'Up to $24.00',
) as $product_id => $expected
) {
- $markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product_id . '} --><!-- wp:woocommerce/product-sale-badge {"badgeContent":"amount"} /--><!-- /wp:woocommerce/single-product -->' );
- $this->assertStringContainsString( 'wc-block-components-product-sale-badge__text" aria-hidden="true">' . $expected . '</span>', $markup );
+ $markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product_id . '} --><div class="wp-block-woocommerce-single-product woocommerce"><!-- wp:woocommerce/product-sale-badge {"badgeContent":"amount"} /--></div><!-- /wp:woocommerce/single-product -->' );
+ $this->assert_sale_badge_labels( $markup, $expected );
}
} finally {
\WC_Tax::_delete_tax_rate( $tax_id );
@@ -294,6 +295,41 @@ class ProductSaleBadge extends \WP_UnitTestCase {
}
}
+ /**
+ * @testdox Variable badges bind their labels and visibility to Interactivity API state.
+ */
+ public function test_variable_badge_interactivity(): void {
+ $product = \WC_Helper_Product::create_variation_product();
+ $variation = wc_get_product( $product->get_children()[0] );
+ $variation->set_sale_price( '5' );
+ $variation->save();
+ $markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><div class="wp-block-woocommerce-single-product woocommerce"><!-- wp:woocommerce/product-sale-badge {"badgeContent":"percentage","prefix":"Save ","suffix":" off"} /--></div><!-- /wp:woocommerce/single-product -->' );
+ $html = new \WP_HTML_Tag_Processor( $markup );
+ $html->next_tag( array( 'class_name' => 'wp-block-woocommerce-product-sale-badge' ) );
+ $context = json_decode( $html->get_attribute( 'data-wp-context' ), true );
+ $this->assertSame( 'woocommerce/product-sale-badge', $html->get_attribute( 'data-wp-interactive' ) );
+ $this->assertSame( 'state.isSaleBadgeHidden', $html->get_attribute( 'data-wp-bind--hidden' ) );
+ $this->assertSame( 'Up to 50%', $context['saleBadgeText'] );
+ $this->assertSame( 'percentage', $context['badgeContent'] );
+ $this->assertSame( 'Save ', $context['prefix'] );
+ $this->assertStringContainsString( 'data-wp-text="state.saleBadgeText"', $markup );
+ $this->assertStringContainsString( 'data-wp-text="state.saleBadgeScreenReaderText"', $markup );
+ $this->assertStringContainsString( '>Up to 50%</span>', $markup );
+ }
+
+ /**
+ * Assert visible and screen-reader sale badge labels without depending on attribute order.
+ *
+ * @param string $markup Rendered block markup.
+ * @param string $expected Expected badge text.
+ */
+ private function assert_sale_badge_labels( string $markup, string $expected ): void {
+ $this->assertStringContainsString( 'wc-block-components-product-sale-badge__text', $markup );
+ $this->assertStringContainsString( 'aria-hidden="true"', $markup );
+ $this->assertStringContainsString( '>' . $expected . '</span>', $markup );
+ $this->assertStringContainsString( 'Product on sale: ' . $expected . '</span>', $markup );
+ }
+
/**
* Alignment values for the Sale Badge.
*
@@ -301,6 +337,7 @@ class ProductSaleBadge extends \WP_UnitTestCase {
*/
public function provider_product_sale_badge_alignment(): array {
return array(
+ 'empty' => array( '' ),
'left' => array( 'left' ),
'center' => array( 'center' ),
'right' => array( 'right' ),
@@ -316,7 +353,7 @@ class ProductSaleBadge extends \WP_UnitTestCase {
$product->set_regular_price( 10 );
$product->set_sale_price( 5 );
$product_id = $product->save();
- $markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product_id . '} --><!-- wp:woocommerce/product-sale-badge /--><!-- /wp:woocommerce/single-product -->' );
+ $markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product_id . '} --><div class="wp-block-woocommerce-single-product woocommerce"><!-- wp:woocommerce/product-sale-badge /--></div><!-- /wp:woocommerce/single-product -->' );
$this->assertStringContainsString( 'wp-block-woocommerce-product-sale-badge', $markup, 'The Single Product Block contains the Product Sale Badge block.' );
$this->assertStringContainsString( 'Sale', $markup, 'The Product Sale Badge block contains the sale text.' );
@@ -349,7 +386,7 @@ class ProductSaleBadge extends \WP_UnitTestCase {
2
);
- $markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product_id . '} --><!-- wp:woocommerce/product-sale-badge /--><!-- /wp:woocommerce/single-product -->' );
+ $markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product_id . '} --><div class="wp-block-woocommerce-single-product woocommerce"><!-- wp:woocommerce/product-sale-badge /--></div><!-- /wp:woocommerce/single-product -->' );
$this->assertStringContainsString( 'wp-block-woocommerce-product-sale-badge', $markup, 'The Single Product Block contains the Product Sale Badge block.' );
$this->assertStringContainsString( 'Special Offer!', $markup, 'The Product Sale Badge block contains the custom sale text.' );