Commit 07998abd44c for woocommerce

commit 07998abd44c2bae0453844f76f4788bec856321e
Author: Tung Du <dinhtungdu@gmail.com>
Date:   Tue May 12 09:41:44 2026 +0700

    Add color swatch support to Product Filter Chips block (#64694)

    **Add color swatch support to Product Filter: Chips block**

    Renders a circular color swatch next to the chip label when the underlying attribute is a `wc-visual` attribute with a hex color set on the term. Items without a color (non-visual attributes or terms with no color meta) render as text-only chips.

    **Implementation**

    - `ProductFilterAttribute.php`: pass term color meta as `item.color` for `wc-visual` attribute types; always include the `color` key for `wc-visual` terms (even when empty) so the swatch detection can check key existence rather than value
    - `ProductFilterChips.php`: render swatch in the SSR `foreach` and in the `data-wp-each` template, gated on the first item carrying a color; sanitize hex values with `sanitize_hex_color()`
    - `chips/frontend.ts`: add `swatchHidden` / `swatchStyle` store getters bound from the template; cache term colors as an instance property for a single source of truth and stable default ref
    - `chips/style.scss`: circular swatch with adaptive border and dual inset shadow for edge contrast; bump vertical padding to `0.4em` for visual balance
    - `product-filters/types.ts`: extend `FilterItemFields` with optional `color`; rename `ItemWithIndex` to `ChipsItem` (the type now carries both index and color fields, so the old name was misleading)

    **Styling**

    - Auto-apply the swatch style when items have a color (no block style picker)
    - Hover and `:focus-visible` border styles for swatch chip buttons
    - Fix duplicated/double border on focused chips

    **Overlay fix**

    Fix Product Filters overlay not filling available width on desktop. The overlay flex child had no `flex-grow`, so it sized to content. With narrow content like swatch circles, the filter block shrank.

    **Notes**

    Feature is behind a feature flag; no changelog entry since it's unreleased.

diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/attribute-filter/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/attribute-filter/edit.tsx
index 7d1861d6d55..a797ea532ba 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/attribute-filter/edit.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/attribute-filter/edit.tsx
@@ -37,6 +37,7 @@ import { Notice } from '../../components/notice';
 import { sortFilterOptions } from '../../utils/sort-filter-options';

 const ATTRIBUTES = getSetting< AttributeSetting[] >( 'attributes', [] );
+const EMPTY_TERM_COLORS: Record< string, string > = {};

 const Edit = ( props: EditProps ) => {
 	const { attributes: blockAttributes } = props;
@@ -52,6 +53,10 @@ const Edit = ( props: EditProps ) => {
 	} = blockAttributes;

 	const attributeObject = getAttributeFromId( attributeId );
+	const termColors = getSetting< Record< string, string > >(
+		'productFilterTermColors',
+		EMPTY_TERM_COLORS
+	);

 	const [ attributeOptions, setAttributeOptions ] = useState<
 		FilterOptionItem[]
@@ -102,6 +107,9 @@ const Edit = ( props: EditProps ) => {
 					value: term.id.toString(),
 					selected: index === 0,
 					...( showCounts && { count: term.count } ),
+					...( term.id in termColors && {
+						color: termColors[ term.id ],
+					} ),
 				} ) );

 			setAttributeOptions(
@@ -119,6 +127,7 @@ const Edit = ( props: EditProps ) => {
 		isTermsLoading,
 		isFilterCountsLoading,
 		attributeObject,
+		termColors,
 	] );

 	const { children, ...innerBlocksProps } = useInnerBlocksProps(
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/edit.tsx
index 2a4d4f6ab02..f71877743f3 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/edit.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/edit.tsx
@@ -55,9 +55,12 @@ const Edit = ( props: EditProps ): JSX.Element => {
 	const { isLoading = false, items = [] } =
 		context?.woocommerceSelectableItems ?? {};

+	const hasColorSwatches = items.some( ( item ) => 'color' in item );
+
 	const blockProps = useBlockProps( {
 		className: clsx( 'wc-block-product-filter-chips', {
 			'is-loading': isLoading,
+			'is-style-swatch': hasColorSwatches,
 			...getColorClasses( attributes ),
 		} ),
 		style: getColorVars( attributes ),
@@ -101,6 +104,24 @@ const Edit = ( props: EditProps ): JSX.Element => {
 								aria-checked={ !! item.selected }
 							>
 								<span className="wc-block-product-filter-chips__label">
+									<span
+										className={ clsx(
+											'wc-block-product-filter-chips__swatch',
+											{
+												'wc-block-product-filter-chips__swatch--no-color':
+													! item.color,
+											}
+										) }
+										style={
+											item.color
+												? {
+														backgroundColor:
+															item.color,
+												  }
+												: undefined
+										}
+										aria-hidden="true"
+									/>
 									<span className="wc-block-product-filter-chips__text">
 										{ typeof item.label === 'string'
 											? decodeHtmlEntities( item.label )
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/frontend.ts
index c34f5beb393..f153f9e5713 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/frontend.ts
@@ -8,7 +8,7 @@ import { store, getContext } from '@wordpress/interactivity';
  */
 import type { SelectableItem } from '../../../../types/type-defs/selectable-items';

-type ItemWithIndex = SelectableItem & { index?: number };
+type ChipsItem = SelectableItem< { color?: string } > & { index?: number };

 type ChipsContext = {
 	storeNamespace: string;
@@ -17,19 +17,21 @@ type ChipsContext = {
 };

 type ParentItemContext = {
-	item?: ItemWithIndex;
+	item?: ChipsItem;
 };

 type ChipsStore = {
 	state: {
 		itemHidden: boolean;
+		swatchHidden: boolean;
+		swatchStyle: string;
 	};
 	actions: {
 		showAll: () => void;
 	};
 };

-function getParentItem( storeNamespace: string ): ItemWithIndex | undefined {
+function getParentItem( storeNamespace: string ): ChipsItem | undefined {
 	const parentCtx = getContext< ParentItemContext >( storeNamespace );
 	return parentCtx.item;
 }
@@ -48,6 +50,17 @@ const { state }: ChipsStore = store< ChipsStore >(
 				if ( item.index === undefined ) return false;
 				return item.index >= displayLimit;
 			},
+			get swatchHidden(): boolean {
+				const { storeNamespace } = getContext< ChipsContext >();
+				const item = getParentItem( storeNamespace );
+				return ! item?.color;
+			},
+			get swatchStyle(): string {
+				const { storeNamespace } = getContext< ChipsContext >();
+				const item = getParentItem( storeNamespace );
+				if ( ! item?.color ) return '';
+				return `background-color: ${ item.color }`;
+			},
 		},
 		actions: {
 			showAll() {
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/style.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/style.scss
index 21fb25e54c7..2da9a67c986 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/style.scss
@@ -10,7 +10,7 @@

 .wc-block-product-filter-chips__item {
 	border: 1px solid color-mix(in srgb, currentColor 40%, transparent);
-	padding: 0.25em 0.75em;
+	padding: 0.4em 0.75em;
 	appearance: none;
 	background: transparent;
 	border-radius: 2px;
@@ -64,6 +64,20 @@
 	display: inline-flex;
 	align-items: center;
 	gap: $gap-smallest;
+	vertical-align: bottom;
+}
+
+.wc-block-product-filter-chips__swatch {
+	display: inline-block;
+	width: 1.25em;
+	height: 1.25em;
+	border-radius: 50%;
+	border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
+	flex-shrink: 0;
+
+	&--no-color {
+		display: none;
+	}
 }

 .wc-block-product-filter-chips__text {
@@ -90,3 +104,63 @@
 		text-decoration: none;
 	}
 }
+
+// Swatch style — color circles only, no text labels.
+.is-style-swatch {
+	.wc-block-product-filter-chips__items {
+		gap: $gap-small;
+	}
+
+	.wc-block-product-filter-chips__item {
+		background: transparent;
+		padding: 2px;
+		border-radius: 50%;
+		border: 1px solid transparent;
+		outline: none;
+
+		&:hover {
+			border-color: color-mix(in srgb, currentColor 40%, transparent);
+		}
+
+		&:focus-visible {
+			border-color: color-mix(in srgb, currentColor 60%, transparent);
+		}
+
+		&[aria-checked="true"] {
+			border-color: var(--wc-product-filters-text-color, CanvasText);
+		}
+	}
+
+	.wc-block-product-filter-chips__label {
+		display: flex;
+		align-items: center;
+		justify-content: center;
+	}
+
+	.wc-block-product-filter-chips__swatch {
+		&--no-color {
+			display: inline-block;
+			background: linear-gradient(
+				to top left,
+				transparent calc(50% - 0.5px),
+				color-mix(in srgb, currentColor 40%, transparent)
+					calc(50% - 0.5px),
+				color-mix(in srgb, currentColor 40%, transparent)
+					calc(50% + 0.5px),
+				transparent calc(50% + 0.5px)
+			);
+		}
+	}
+
+	.wc-block-product-filter-chips__text,
+	.wc-block-product-filter-chips__count {
+		display: block;
+		position: absolute;
+		width: 1px;
+		height: 1px;
+		overflow: hidden;
+		clip: rect(0 0 0 0);
+		clip-path: inset(50%);
+		white-space: nowrap;
+	}
+}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/style.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/style.scss
index 855757c9567..11c8cc4c6c7 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/style.scss
@@ -128,6 +128,7 @@
 				background: inherit;
 				color: inherit;
 				transition: none;
+				flex-grow: 1;
 			}
 			.wc-block-product-filters__overlay-wrapper {
 				width: auto;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/types.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/types.ts
index ebca526bfc7..780f01536a0 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/types.ts
@@ -18,6 +18,7 @@ export type FilterItemFields = {
 	depth?: number;
 	menuOrder?: number;
 	attributeQueryType?: 'and' | 'or';
+	color?: string;
 };

 export type FilterOptionItem = SelectableItem< FilterItemFields >;
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php
index e69e8841abe..abc09e8c2a5 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php
@@ -19,6 +19,13 @@ final class ProductFilterAttribute extends AbstractBlock {
 	 */
 	protected $block_name = 'product-filter-attribute';

+	/**
+	 * Cached map of term ID to color value for all wc-visual attribute terms.
+	 *
+	 * @var array<int, string>|null
+	 */
+	private $term_colors = null;
+
 	/**
 	 * Initialize this block type.
 	 *
@@ -45,7 +52,48 @@ final class ProductFilterAttribute extends AbstractBlock {

 		if ( is_admin() ) {
 			$this->asset_data_registry->add( 'defaultProductFilterAttribute', $this->get_default_product_attribute() );
+			$this->asset_data_registry->add( 'productFilterTermColors', $this->get_visual_attribute_term_colors() );
+		}
+	}
+
+	/**
+	 * Get color values for all wc-visual attribute terms.
+	 *
+	 * @return array<int, string> Map of term ID to hex color.
+	 */
+	private function get_visual_attribute_term_colors(): array {
+		if ( null !== $this->term_colors ) {
+			return $this->term_colors;
+		}
+
+		$colors     = array();
+		$attributes = wc_get_attribute_taxonomies();
+
+		foreach ( $attributes as $attribute ) {
+			if ( 'wc-visual' !== $attribute->attribute_type ) {
+				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 ) );
+				$colors[ $term->term_id ] = $color ? $color : '';
+			}
 		}
+
+		$this->term_colors = $colors;
+
+		return $this->term_colors;
 	}

 	/**
@@ -213,6 +261,11 @@ final class ProductFilterAttribute extends AbstractBlock {
 						$item['count'] = $term['count'];
 					}

+					if ( 'wc-visual' === $product_attribute->type ) {
+						$colors        = $this->get_visual_attribute_term_colors();
+						$item['color'] = $colors[ $term['term_id'] ] ?? '';
+					}
+
 					return $item;
 				},
 				$attribute_terms
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterChips.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterChips.php
index 9b014decbf0..9f32265dcbd 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterChips.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterChips.php
@@ -68,11 +68,17 @@ final class ProductFilterChips extends AbstractBlock {
 			$wrapper_attributes['style'] = esc_attr( $style ) . ';';
 		}

-		$visible_items  = array_slice( $items, 0, $display_limit, true );
-		$has_more_items = count( $items ) > count( $visible_items );
-		$hidden_count   = max( 0, count( $items ) - count( $visible_items ) );
-		$first_item     = reset( $items );
-		$show_counts    = is_array( $first_item ) && array_key_exists( 'count', $first_item );
+		$visible_items      = array_slice( $items, 0, $display_limit, true );
+		$has_more_items     = count( $items ) > count( $visible_items );
+		$hidden_count       = max( 0, count( $items ) - count( $visible_items ) );
+		$first_item         = reset( $items );
+		$show_counts        = is_array( $first_item ) && array_key_exists( 'count', $first_item );
+		$has_color_swatches = is_array( $first_item ) && array_key_exists( 'color', $first_item );
+
+		if ( $has_color_swatches && is_string( $classes ) && ! str_contains( $classes, 'is-style-swatch' ) ) {
+			$classes                    .= ' is-style-swatch';
+			$wrapper_attributes['class'] = esc_attr( $classes );
+		}

 		ob_start();
 		?>
@@ -108,6 +114,15 @@ final class ProductFilterChips extends AbstractBlock {
 							data-wp-on--click="actions.toggle"
 						>
 							<span class="wc-block-product-filter-chips__label">
+								<?php if ( $has_color_swatches ) : ?>
+									<span
+										class="wc-block-product-filter-chips__swatch<?php echo empty( $item['color'] ) ? ' wc-block-product-filter-chips__swatch--no-color' : ''; ?>"
+										<?php if ( ! empty( $item['color'] ) ) : ?>
+											style="background-color: <?php echo esc_attr( $item['color'] ); ?>;"
+										<?php endif; ?>
+										aria-hidden="true"
+									></span>
+								<?php endif; ?>
 								<span class="wc-block-product-filter-chips__text">
 									<?php echo esc_html( $item['label'] ); ?>
 								</span>
@@ -136,6 +151,14 @@ final class ProductFilterChips extends AbstractBlock {
 							data-wp-on--click="actions.toggle"
 						>
 							<span class="wc-block-product-filter-chips__label">
+								<?php if ( $has_color_swatches ) : ?>
+									<span
+										class="wc-block-product-filter-chips__swatch"
+										data-wp-class--wc-block-product-filter-chips__swatch--no-color="woocommerce/product-filter-chips::state.swatchHidden"
+										data-wp-bind--style="woocommerce/product-filter-chips::state.swatchStyle"
+										aria-hidden="true"
+									></span>
+								<?php endif; ?>
 								<span
 									class="wc-block-product-filter-chips__text"
 									data-wp-text="context.item.label"