Commit 4a9ee9b5416 for woocommerce

commit 4a9ee9b5416304ca81ebad3e92ad0655a05cb985
Author: Albert Juhé Lluveras <contact@albertjuhe.com>
Date:   Thu May 21 15:39:39 2026 +0200

    Update Variation Selector block so it displays swatches for wc-visual attributes (#65180)

    * Update Variation Selector block so it displays swatches for wc-visual attributes

    * Add changelog

    * Make default term colors default setting an empty object

    * Minor fixes

    * Clean up

    * Don't save empty colors unnecessarily

diff --git a/plugins/woocommerce/changelog/update-variation-selector-color-swatches b/plugins/woocommerce/changelog/update-variation-selector-color-swatches
new file mode 100644
index 00000000000..3259db137cd
--- /dev/null
+++ b/plugins/woocommerce/changelog/update-variation-selector-color-swatches
@@ -0,0 +1,5 @@
+Significance: patch
+Type: update
+Comment: Update Variation Selector block so it displays swatches for wc-visual attributes
+
+
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/constants.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/constants.ts
index c643193027a..20917f21a71 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/constants.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/constants.ts
@@ -48,9 +48,9 @@ export const DEFAULT_ATTRIBUTES = [
 		name: __( 'Color', 'woocommerce' ),
 		has_variations: true,
 		terms: [
-			{ id: 1, slug: 'blue', name: __( 'Blue', 'woocommerce' ) },
-			{ id: 2, slug: 'red', name: __( 'Red', 'woocommerce' ) },
-			{ id: 3, slug: 'green', name: __( 'Green', 'woocommerce' ) },
+			{ id: -1, slug: 'blue', name: __( 'Blue', 'woocommerce' ) },
+			{ id: -2, slug: 'red', name: __( 'Red', 'woocommerce' ) },
+			{ id: -3, slug: 'green', name: __( 'Green', 'woocommerce' ) },
 		],
 	},
 	{
@@ -65,3 +65,9 @@ export const DEFAULT_ATTRIBUTES = [
 		],
 	},
 ] as const;
+
+export const EMPTY_TERM_COLORS: Record< string, string > = {
+	'-1': '#0000ff',
+	'-2': '#e10000',
+	'-3': '#009b00',
+};
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/edit.tsx
index 17bed069750..d442058dd77 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/edit.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/edit.tsx
@@ -20,6 +20,7 @@ import {
 import { isProductResponseItem } from '@woocommerce/entities';
 import type { ProductResponseAttributeItem } from '@woocommerce/types';
 import { __ } from '@wordpress/i18n';
+import { getSetting } from '@woocommerce/settings';
 import {
 	ToggleControl,
 	__experimentalToggleGroupControl as ToggleGroupControl,
@@ -31,12 +32,15 @@ import {
 /**
  * Internal dependencies
  */
-import { DEFAULT_ATTRIBUTES } from './constants';
+import { DEFAULT_ATTRIBUTES, EMPTY_TERM_COLORS } from './constants';
 import {
 	DisplayStyleSwitcher,
 	resetDisplayStyleBlock,
 } from '../../../product-filters/components/display-style-switcher';
-import type { SelectableItemsContext } from '../../../../types/type-defs/selectable-items';
+import type {
+	SelectableItem,
+	SelectableItemsContext,
+} from '../../../../types/type-defs/selectable-items';

 const INNER_CHIPS = 'woocommerce/product-filter-chips';

@@ -57,19 +61,36 @@ function AttributeItem( { blocks, isSelected, onSelect }: AttributeItemProps ) {
 	const { data: attribute } =
 		useCustomDataContext< ProductResponseAttributeItem >( 'attribute' );

+	const termColors = getSetting< Record< string, string > >(
+		'variationSelectorTermColors',
+		{} as Record< string, string >
+	);
+
 	const selectableContext = useMemo( () => {
-		let items = [];
+		let items: SelectableItem< {
+			label: string;
+			ariaLabel: string;
+		} >[] = [];
 		if (
 			attribute &&
 			Array.isArray( attribute?.terms ) &&
 			attribute.terms.length > 0
 		) {
-			items = attribute.terms.map( ( term ) => ( {
-				id: `${ attribute.taxonomy }-${ term.slug }`,
-				label: term.name,
-				value: term.slug,
-				ariaLabel: term.name,
-			} ) );
+			items = attribute.terms.map( ( term ) => {
+				let color: string | null = null;
+				if ( term.id in termColors ) {
+					color = termColors[ term.id ];
+				} else if ( term.id in EMPTY_TERM_COLORS ) {
+					color = EMPTY_TERM_COLORS[ term.id ];
+				}
+				return {
+					id: `${ attribute.taxonomy }-${ term.slug }`,
+					label: term.name,
+					value: term.slug,
+					ariaLabel: term.name,
+					...( color !== null ? { color } : {} ),
+				};
+			} );
 		}

 		return {
@@ -81,7 +102,7 @@ function AttributeItem( { blocks, isSelected, onSelect }: AttributeItemProps ) {
 			label: string;
 			ariaLabel: string;
 		} >;
-	}, [ attribute ] );
+	}, [ attribute, termColors ] );

 	const blockPreviewProps = useBlockPreview( {
 		blocks,
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/frontend.ts
index 2037075c6f7..e187dfa1982 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/frontend.ts
@@ -31,6 +31,7 @@ type VariationOptionItem = {
 	label: string;
 	value: string;
 	ariaLabel?: string;
+	color?: string;
 };

 type Context = AddToCartWithOptionsStoreContext & {
@@ -248,6 +249,7 @@ const { actions, state } = store< VariableProductAddToCartWithOptionsStore >(
 						selected,
 						disabled,
 						hidden: hideInvalid && disabled,
+						...( row.color !== undefined && { color: row.color } ),
 					};
 				} );
 			},
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/VariationSelectorAttribute.php b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/VariationSelectorAttribute.php
index 819dce6a4a9..d44bfefc528 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/VariationSelectorAttribute.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/VariationSelectorAttribute.php
@@ -21,6 +21,60 @@ class VariationSelectorAttribute extends AbstractBlock {
 	 */
 	protected $block_name = 'add-to-cart-with-options-variation-selector-attribute';

+	/**
+	 * Extra data passed through from server to client for block.
+	 *
+	 * @param array $attributes Any attributes that currently are available from the block.
+	 * @return void
+	 */
+	protected function enqueue_data( array $attributes = array() ) {
+		parent::enqueue_data( $attributes );
+
+		if ( is_admin() ) {
+			$this->asset_data_registry->add( 'variationSelectorTermColors', $this->get_visual_attribute_term_colors() );
+		}
+	}
+
+	/**
+	 * Get color values for all wc-visual attribute terms.
+	 *
+	 * @param string|null $attribute_name Optional product attribute taxonomy name (e.g. `pa_color`). When omitted, colors for every wc-visual attribute are loaded.
+	 * @return array<int, string|null> Map of term ID to hex color.
+	 */
+	private function get_visual_attribute_term_colors( ?string $attribute_name = null ): array {
+		$colors     = array();
+		$attributes = wc_get_attribute_taxonomies();
+
+		foreach ( $attributes as $attribute ) {
+			if ( 'wc-visual' !== $attribute->attribute_type ) {
+				continue;
+			}
+			if ( $attribute_name && 'pa_' . $attribute->attribute_name !== $attribute_name ) {
+				continue;
+			}
+
+			$terms = get_terms(
+				array(
+					'taxonomy'   => 'pa_' . $attribute->attribute_name,
+					'hide_empty' => false,
+				)
+			);
+
+			if ( is_wp_error( $terms ) ) {
+				continue;
+			}
+
+			foreach ( $terms as $term ) {
+				$color = sanitize_hex_color( get_term_meta( $term->term_id, 'color', true ) );
+				if ( ! empty( $color ) ) {
+					$colors[ $term->term_id ] = $color;
+				}
+			}
+		}
+
+		return $colors;
+	}
+
 	/**
 	 * Render the block.
 	 *
@@ -77,7 +131,7 @@ class VariationSelectorAttribute extends AbstractBlock {
 		}

 		$default_selected = $this->get_default_selected_attribute( $attribute_slug, $attribute_terms );
-		$variation_items  = $this->build_variation_selectable_items( $attribute_slug, $attribute_terms, $default_selected );
+		$variation_items  = $this->build_variation_selectable_items( $attribute_name, $attribute_slug, $attribute_terms, $default_selected );
 		$attribute_label  = wc_attribute_label( $attribute_name );
 		$attribute_id     = 'wc_product_attribute_' . uniqid();
 		$context          = array(
@@ -247,28 +301,36 @@ class VariationSelectorAttribute extends AbstractBlock {
 	/**
 	 * Build selectable items for the inner block protocol and client context.
 	 *
+	 * @param string      $attribute_name Product attribute name.
 	 * @param string      $attribute_slug Attribute slug.
 	 * @param array       $attribute_terms Terms from context.
 	 * @param string|null $default_selected Default selected attribute value.
 	 * @return array
 	 */
-	private function build_variation_selectable_items( string $attribute_slug, array $attribute_terms, ?string $default_selected ): array {
-		$id_prefix = sanitize_title( $attribute_slug );
-		$items     = array();
+	private function build_variation_selectable_items( string $attribute_name, string $attribute_slug, array $attribute_terms, ?string $default_selected ): array {
+		$id_prefix   = sanitize_title( $attribute_slug );
+		$items       = array();
+		$term_colors = $this->get_visual_attribute_term_colors( $attribute_name );

 		foreach ( $attribute_terms as $attribute_term ) {
 			if ( ! is_array( $attribute_term ) || ! isset( $attribute_term['value'], $attribute_term['label'] ) ) {
 				continue;
 			}
-			$value   = (string) $attribute_term['value'];
-			$slug    = sanitize_title( $value );
-			$items[] = array(
+			$value = (string) $attribute_term['value'];
+			$slug  = sanitize_title( $value );
+			$item  = array(
 				'id'        => $id_prefix . '-' . $slug,
 				'label'     => (string) $attribute_term['label'],
 				'value'     => $value,
 				'ariaLabel' => (string) $attribute_term['label'],
 				'selected'  => $default_selected === $value,
 			);
+
+			if ( ! empty( $term_colors ) && isset( $attribute_term['term_id'], $term_colors[ $attribute_term['term_id'] ] ) ) {
+				$item['color'] = $term_colors[ $attribute_term['term_id'] ];
+			}
+
+			$items[] = $item;
 		}

 		return $items;
@@ -296,7 +358,7 @@ class VariationSelectorAttribute extends AbstractBlock {
 			return array();
 		}

-		return array(
+		$option = array(
 			'value'      => $value,
 			/**
 			 * Filter the variation option name.
@@ -317,5 +379,11 @@ class VariationSelectorAttribute extends AbstractBlock {
 			),
 			'isSelected' => $selected_attribute === $value,
 		);
+
+		if ( $term instanceof \WP_Term ) {
+			$option['term_id'] = $term->term_id;
+		}
+
+		return $option;
 	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/VariationSelectorAttribute.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/VariationSelectorAttribute.php
index 3e2885b9068..51dafe8b1aa 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/VariationSelectorAttribute.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/VariationSelectorAttribute.php
@@ -146,6 +146,94 @@ class VariationSelectorAttribute extends WC_Unit_Test_Case {
 		$this->assertStringContainsString( '"disabledAttributesAction":"hide"', $markup, 'Legacy disabledAttributesAction should be applied to the interactivity context.' );
 	}

+	/**
+	 * Tests that wc-visual attribute terms render chips with swatch markup and classes.
+	 *
+	 * @testdox VariationSelectorAttribute renders wc-visual attribute options with swatch classes and colors.
+	 * @covers \Automattic\WooCommerce\Blocks\BlockTypes\AddToCartWithOptions\VariationSelectorAttribute::build_variation_selectable_items
+	 */
+	public function test_renders_wc_visual_attribute_with_swatch_classes(): void {
+		global $wpdb;
+
+		$fixtures     = new FixtureData();
+		$attribute    = FixtureData::get_product_attribute(
+			'vswatch',
+			array( 'Tone A', 'Tone B' )
+		);
+		$attribute_id = $attribute['attribute_id'];
+		$taxonomy     = $attribute['attribute_taxonomy'];
+		$term_ids     = $attribute['term_ids'];
+		$term_a       = get_term( $term_ids[0] );
+		$term_b       = get_term( $term_ids[1] );
+		$this->assertInstanceOf( \WP_Term::class, $term_a );
+		$this->assertInstanceOf( \WP_Term::class, $term_b );
+
+		$wpdb->update(
+			$wpdb->prefix . 'woocommerce_attribute_taxonomies',
+			array( 'attribute_type' => 'wc-visual' ),
+			array( 'attribute_id' => $attribute_id ),
+			array( '%s' ),
+			array( '%d' )
+		);
+		delete_transient( 'wc_attribute_taxonomies' );
+		\WC_Cache_Helper::invalidate_cache_group( 'woocommerce-attributes' );
+
+		update_term_meta( $term_a->term_id, 'color', '#aa0000' );
+		update_term_meta( $term_b->term_id, 'color', '#0000aa' );
+
+		try {
+			$variable_product = $fixtures->get_variable_product(
+				array(),
+				array( $attribute )
+			);
+
+			$product_id = $variable_product->get_id();
+
+			$fixtures->get_variation_product(
+				$product_id,
+				array( $taxonomy => $term_a->slug ),
+				array(
+					'regular_price' => 10,
+					'stock_status'  => ProductStockStatus::IN_STOCK,
+				)
+			);
+
+			$fixtures->get_variation_product(
+				$product_id,
+				array( $taxonomy => $term_b->slug ),
+				array(
+					'regular_price' => 12,
+					'stock_status'  => ProductStockStatus::IN_STOCK,
+				)
+			);
+
+			\WC_Product_Variable::sync( $product_id );
+
+			$variable_product = wc_get_product( $product_id );
+			$this->assertInstanceOf( \WC_Product_Variable::class, $variable_product );
+
+			$inner_blocks = $this->get_attribute_name_block_markup() . $this->get_chips_block_markup();
+			$markup       = $this->render_variation_selector_attribute( $variable_product, $inner_blocks );
+
+			$this->assertStringContainsString( 'is-style-swatch', $markup, 'Chips wrapper should use swatch style when colors are present.' );
+			$this->assertStringContainsString( 'wc-block-product-filter-chips__swatch', $markup, 'Swatch elements should be rendered for wc-visual terms.' );
+			$this->assertStringContainsString( 'background-color: #aa0000', $markup, 'First term swatch should use its term color meta.' );
+			$this->assertStringContainsString( 'background-color: #0000aa', $markup, 'Second term swatch should use its term color meta.' );
+		} finally {
+			delete_term_meta( $term_a->term_id, 'color' );
+			delete_term_meta( $term_b->term_id, 'color' );
+			$wpdb->update(
+				$wpdb->prefix . 'woocommerce_attribute_taxonomies',
+				array( 'attribute_type' => 'select' ),
+				array( 'attribute_id' => $attribute_id ),
+				array( '%s' ),
+				array( '%d' )
+			);
+			delete_transient( 'wc_attribute_taxonomies' );
+			\WC_Cache_Helper::invalidate_cache_group( 'woocommerce-attributes' );
+		}
+	}
+
 	/**
 	 * Tests that legacy Attribute Options blocks nested in a group are replaced when rendered.
 	 *
@@ -249,6 +337,15 @@ class VariationSelectorAttribute extends WC_Unit_Test_Case {
 		return '<!-- wp:woocommerce/add-to-cart-with-options-variation-selector-attribute-name /-->';
 	}

+	/**
+	 * Block markup for the Chips inner block (variation selector default).
+	 *
+	 * @return string
+	 */
+	private function get_chips_block_markup(): string {
+		return '<!-- wp:woocommerce/product-filter-chips /-->';
+	}
+
 	/**
 	 * Get block markup for the legacy Attribute Options inner block.
 	 *