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"