Commit f7a837a67ad for woocommerce
commit f7a837a67ad80aa56dd49fdfbef799f915a65d42
Author: Tung Du <dinhtungdu@gmail.com>
Date: Tue Oct 27 17:12:42 2026 +0700
feat: add multiple-selection Product Filters dropdowns (#70118)
* feat: expose complete selected product filter options
Keep plain labels in active filter payloads and derive selectedItems from all active selections, including options hidden by another filter.
Preserve fallback labels for existing payloads and cover unavailable selections in the parent store test.
Amp-Thread-ID: T-01a15d32-e7f4-7608-a50c-7b7ee85329eb
* feat: add multiple-selection product filter dropdowns
Render a checkbox panel for multiple-selection context and retain the native select for single-selection context.
Reuse List styling and the parent selection action. Keep panel dismissal and focus handling local to Dropdown, summarize complete selections, and apply Core color and border supports in the editor and storefront.
Amp-Thread-ID: T-01a15d32-e7f4-7608-a50c-7b7ee85329eb
* test: cover product filter dropdown behavior and styling
Verify results after each selection, keyboard focus and nested Escape dismissal, reload and server-rendered summaries, theme overrides, Core styles, and editor background inheritance.
Amp-Thread-ID: T-01a15d32-e7f4-7608-a50c-7b7ee85329eb
* changelog: add multiple-selection dropdown entry
Amp-Thread-ID: T-01a15d32-e7f4-7608-a50c-7b7ee85329eb
* docs: refresh Dropdown block reference
Amp-Thread-ID: https://ampcode.com/threads/T-01a15d32-e7f4-7608-a50c-7b7ee85329eb
Co-authored-by: Amp <amp@ampcode.com>
* test: fix Dropdown stylesheet path for shared CI builds
Amp-Thread-ID: https://ampcode.com/threads/T-01a15d32-e7f4-7608-a50c-7b7ee85329eb
Co-authored-by: Amp <amp@ampcode.com>
---------
Co-authored-by: Amp <amp@ampcode.com>
diff --git a/docs/block-development/reference/block-references.md b/docs/block-development/reference/block-references.md
index 29296af0e8a..71680282f1b 100644
--- a/docs/block-development/reference/block-references.md
+++ b/docs/block-development/reference/block-references.md
@@ -698,8 +698,8 @@ Display options in a dropdown.
- **Name:** woocommerce/dropdown
- **Category:** woocommerce
-- **Ancestor:** woocommerce/add-to-cart-with-options-variation-selector-attribute
-- **Supports:** interactivity, woocommerce (innerBlockDisplayStyle)
+- **Ancestor:** woocommerce/add-to-cart-with-options-variation-selector-attribute, woocommerce/product-filter-attribute, woocommerce/product-filter-taxonomy, woocommerce/product-filter-status
+- **Supports:** color (background, text), interactivity, woocommerce (innerBlockDisplayStyle)
## Email Content - woocommerce/email-content
diff --git a/plugins/woocommerce/changelog/add-product-filter-dropdown b/plugins/woocommerce/changelog/add-product-filter-dropdown
new file mode 100644
index 00000000000..e60ebad8bc4
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-product-filter-dropdown
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a multiple-selection Dropdown display style for attribute, category, and status product filters.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/block.json b/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/block.json
index 53a661ef35c..f9e5252bde4 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/block.json
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/block.json
@@ -8,14 +8,32 @@
"textdomain": "woocommerce",
"apiVersion": 3,
"ancestor": [
- "woocommerce/add-to-cart-with-options-variation-selector-attribute"
+ "woocommerce/add-to-cart-with-options-variation-selector-attribute",
+ "woocommerce/product-filter-attribute",
+ "woocommerce/product-filter-taxonomy",
+ "woocommerce/product-filter-status"
],
"supports": {
"interactivity": true,
"woocommerce": {
"innerBlockDisplayStyle": true
+ },
+ "color": {
+ "text": true,
+ "background": true,
+ "__experimentalSkipSerialization": true
+ },
+ "__experimentalBorder": {
+ "color": true,
+ "width": true,
+ "style": true,
+ "radius": true,
+ "__experimentalSkipSerialization": true
}
},
+ "selectors": {
+ "root": ".wp-block-woocommerce-dropdown :is(.wc-block-dropdown__select, .wc-block-dropdown__trigger, .wc-block-dropdown__panel)"
+ },
"usesContext": [ "woocommerce/selectableItems", "woocommerce/attributeId" ],
"attributes": {},
"viewScriptModule": "woocommerce/dropdown",
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/edit.tsx
index 0f60b1985f7..1ba4bfb2d97 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/edit.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/edit.tsx
@@ -5,23 +5,39 @@ import { __ } from '@wordpress/i18n';
import clsx from 'clsx';
import { decodeHtmlEntities } from '@woocommerce/utils';
import { Disabled } from '@wordpress/components';
-import { useBlockProps } from '@wordpress/block-editor';
-import type { BlockEditProps } from '@wordpress/blocks';
+import { useRefEffect } from '@wordpress/compose';
+import {
+ useBlockProps,
+ // @ts-expect-error - no types.
+ // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
+ __experimentalUseColorProps as useColorProps,
+ // @ts-expect-error - no types.
+ // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
+ __experimentalUseBorderProps as useBorderProps,
+} from '@wordpress/block-editor';
+import type { BlockAttributes, BlockEditProps } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import type { SelectableItemsBlockContext } from '@woocommerce/types';
+import { getSelectionSummary } from './utils';
+import {
+ getVisualAttributeTermStyle,
+ isVisualAttributeTermEmpty,
+} from '../../base/utils/visual-attribute-terms';
+import type { VisualAttributeTerm } from '../../base/utils/visual-attribute-terms';
+import { getClosestColor } from '../product-filters/utils/get-closest-color';
import './editor.scss';
import './style.scss';
-export type BlockAttributes = Record< string, never >;
-
export type EditProps = BlockEditProps< BlockAttributes > & {
- context: SelectableItemsBlockContext< {
- label?: string;
- ariaLabel?: string;
- } >;
+ context: Partial<
+ SelectableItemsBlockContext< {
+ count?: number;
+ visual?: VisualAttributeTerm;
+ } >
+ >;
};
function getOptionLabel( item: {
@@ -40,20 +56,171 @@ function getOptionLabel( item: {
return '';
}
-const Edit = ( props: EditProps ): JSX.Element => {
- const { context } = props;
- const selectableItems = context?.[ 'woocommerce/selectableItems' ] ?? {};
- const isLoading = selectableItems.isLoading ?? false;
- const items = Array.isArray( selectableItems.items )
+const Edit = ( props: EditProps ) => {
+ const { context, attributes, isSelected, clientId } = props;
+ const selectableItems = context?.[ 'woocommerce/selectableItems' ];
+ const isLoading = selectableItems?.isLoading ?? false;
+ const items = Array.isArray( selectableItems?.items )
? selectableItems.items
: [];
+ const isMultiple = selectableItems?.selectionMode === 'multiple';
+ const borderProps = useBorderProps( attributes );
+ const colorProps = useColorProps( attributes );
+ const fieldProps = {
+ className: clsx( borderProps.className, colorProps.className ),
+ style: { ...borderProps.style, ...colorProps.style },
+ };
+ const backgroundRef = useRefEffect( ( node: HTMLDivElement ) => {
+ const updateBackground = () => {
+ const background = getClosestColor( node, 'backgroundColor' );
+ if ( background ) {
+ node.style.setProperty(
+ '--wc-dropdown-background',
+ background
+ );
+ }
+ };
+ const observer = new MutationObserver( updateBackground );
+ for (
+ let ancestor = node.parentElement;
+ ancestor;
+ ancestor = ancestor.parentElement
+ ) {
+ observer.observe( ancestor, {
+ attributes: true,
+ attributeFilter: [ 'class', 'style' ],
+ } );
+ }
+ updateBackground();
+ return () => observer.disconnect();
+ }, [] );
const blockProps = useBlockProps( {
+ ref: backgroundRef,
className: clsx( 'wc-block-dropdown', {
'is-loading': isLoading,
+ 'wc-block-dropdown--multiple': isMultiple,
} ),
} );
+ if ( isMultiple ) {
+ return (
+ <div { ...blockProps }>
+ <Disabled>
+ <button
+ type="button"
+ className={ clsx(
+ 'wc-block-dropdown__trigger',
+ fieldProps.className
+ ) }
+ style={ {
+ ...fieldProps.style,
+ ...( isSelected && {
+ borderBottomLeftRadius: 0,
+ borderBottomRightRadius: 0,
+ } ),
+ } }
+ aria-expanded={ isSelected }
+ >
+ <span className="wc-block-dropdown__summary">
+ { getSelectionSummary(
+ items,
+ __( 'Choose options', 'woocommerce' )
+ ) }
+ </span>
+ <span
+ className="wc-block-dropdown__chevron"
+ aria-hidden="true"
+ />
+ </button>
+ <div
+ className={ clsx(
+ 'wc-block-dropdown__panel wc-block-product-filter-checkbox-list',
+ fieldProps.className
+ ) }
+ style={ {
+ ...fieldProps.style,
+ borderTopWidth: 0,
+ borderTopLeftRadius: 0,
+ borderTopRightRadius: 0,
+ } }
+ hidden={ ! isSelected }
+ >
+ { isLoading && (
+ <div className="wc-block-dropdown__skeleton-option" />
+ ) }
+ { items
+ .filter( ( item ) => ! item.hidden )
+ .map( ( item ) => (
+ <div
+ className="wc-block-product-filter-checkbox-list__item"
+ key={ item.value }
+ >
+ <label
+ className="wc-block-product-filter-checkbox-list__label"
+ htmlFor={ `${ clientId }-${ item.value }` }
+ >
+ <span className="wc-block-product-filter-checkbox-list__input-wrapper">
+ <input
+ id={ `${ clientId }-${ item.value }` }
+ type="checkbox"
+ className="wc-block-product-filter-checkbox-list__input"
+ defaultChecked={
+ !! item.selected
+ }
+ disabled={ !! item.disabled }
+ />
+ <svg
+ viewBox="0 0 10 8"
+ fill="none"
+ className="wc-block-product-filter-checkbox-list__mark"
+ aria-hidden="true"
+ >
+ <path
+ d="M9.25 1.19922L3.75 6.69922L1 3.94922"
+ stroke="currentColor"
+ strokeWidth="1.5"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ />
+ </svg>
+ </span>
+ <span className="wc-block-product-filter-checkbox-list__text-wrapper">
+ { item.visual !== undefined && (
+ <span
+ className={ clsx(
+ 'wc-block-product-filter-checkbox-list__color-swatch',
+ {
+ 'is-empty':
+ isVisualAttributeTermEmpty(
+ item.visual
+ ),
+ }
+ ) }
+ style={ getVisualAttributeTermStyle(
+ item.visual
+ ) }
+ aria-hidden="true"
+ />
+ ) }
+ <span className="wc-block-product-filter-checkbox-list__text">
+ { getOptionLabel( item ) }
+ </span>
+ { item.count !== undefined && (
+ <span className="wc-block-product-filter-checkbox-list__count">
+ ({ item.count })
+ </span>
+ ) }
+ </span>
+ </label>
+ </div>
+ ) ) }
+ </div>
+ </Disabled>
+ </div>
+ );
+ }
+
return (
<div { ...blockProps }>
<Disabled>
@@ -67,8 +234,12 @@ const Edit = ( props: EditProps ): JSX.Element => {
</div>
) : (
<select
- className="wc-block-dropdown__select"
- aria-label={ selectableItems.groupLabel }
+ className={ clsx(
+ 'wc-block-dropdown__select',
+ fieldProps.className
+ ) }
+ style={ fieldProps.style }
+ aria-label={ selectableItems?.groupLabel }
>
<option value="">
{ __( 'Choose an option', 'woocommerce' ) }
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/frontend.ts
index 740d48a7ced..2e4a78e9ad2 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/frontend.ts
@@ -1,20 +1,37 @@
/**
* External dependencies
*/
-import { store, getContext } from '@wordpress/interactivity';
+import {
+ store,
+ getContext,
+ getElement,
+ withSyncEvent,
+} from '@wordpress/interactivity';
/**
* Internal dependencies
*/
import type { SelectableItem } from '@woocommerce/types';
+import { getSelectionSummary } from './utils';
+import {
+ getVisualAttributeTermStyleString,
+ isVisualAttributeTermEmpty,
+} from '../../base/utils/visual-attribute-terms';
+import type { VisualAttributeTerm } from '../../base/utils/visual-attribute-terms';
+import { getClosestColor } from '../product-filters/utils/get-closest-color';
type DropdownContext = {
storeNamespace: string;
+ placeholder: string;
+ isOpen: boolean;
+ fieldStyle: string;
+ item: SelectableItem< { visual?: VisualAttributeTerm } >;
};
type SelectableParent = {
state: {
selectableItems: readonly SelectableItem[];
+ selectedItems?: readonly SelectableItem[];
};
actions: {
toggle: ( item?: SelectableItem ) => void;
@@ -38,6 +55,39 @@ store(
'woocommerce/dropdown',
{
state: {
+ get triggerStyle(): string {
+ const { fieldStyle, isOpen } = getContext< DropdownContext >();
+ return (
+ fieldStyle +
+ ( isOpen
+ ? 'border-bottom-left-radius:0;border-bottom-right-radius:0;'
+ : '' )
+ );
+ },
+ get items() {
+ const { storeNamespace } = getContext< DropdownContext >();
+ return store< SelectableParent >( storeNamespace ).state
+ .selectableItems;
+ },
+ get selectionSummary(): string {
+ const { storeNamespace, placeholder } =
+ getContext< DropdownContext >();
+ const parent = store< SelectableParent >( storeNamespace );
+ return getSelectionSummary(
+ parent.state.selectedItems ?? parent.state.selectableItems,
+ placeholder
+ );
+ },
+ get colorSwatchStyle(): string {
+ return getVisualAttributeTermStyleString(
+ getContext< DropdownContext >().item.visual
+ );
+ },
+ get isColorSwatchEmpty(): boolean {
+ return isVisualAttributeTermEmpty(
+ getContext< DropdownContext >().item.visual
+ );
+ },
get selectValue(): string {
const { storeNamespace } = getContext< DropdownContext >();
const parent = store< SelectableParent >( storeNamespace );
@@ -49,6 +99,48 @@ store(
},
},
actions: {
+ togglePanel() {
+ const context = getContext< DropdownContext >();
+ context.isOpen = ! context.isOpen;
+ },
+ toggleItem() {
+ const { storeNamespace, item } =
+ getContext< DropdownContext >();
+ if ( isToggleableItem( item ) ) {
+ store< SelectableParent >( storeNamespace ).actions.toggle(
+ item
+ );
+ }
+ },
+ onKeydown: withSyncEvent( ( event: KeyboardEvent ) => {
+ const context = getContext< DropdownContext >();
+ const { ref } = getElement();
+ if ( event.key === 'Escape' && context.isOpen ) {
+ event.stopPropagation();
+ context.isOpen = false;
+ ref?.querySelector< HTMLButtonElement >(
+ '.wc-block-dropdown__trigger'
+ )?.focus();
+ }
+ } ),
+ closeOnClickOutside( event: MouseEvent ) {
+ const { ref } = getElement();
+ if (
+ event.target instanceof Node &&
+ ! ref?.contains( event.target )
+ ) {
+ getContext< DropdownContext >().isOpen = false;
+ }
+ },
+ closeOnFocusOutside( event: FocusEvent ) {
+ const { ref } = getElement();
+ if (
+ ! ( event.relatedTarget instanceof Node ) ||
+ ! ref?.contains( event.relatedTarget )
+ ) {
+ getContext< DropdownContext >().isOpen = false;
+ }
+ },
onDropdownChange( event: Event ) {
const target = event.currentTarget;
if ( ! ( target instanceof HTMLSelectElement ) ) {
@@ -88,6 +180,18 @@ store(
parent.actions.toggle( row );
},
},
+ callbacks: {
+ initColors() {
+ const { ref } = getElement();
+ const background = getClosestColor( ref, 'backgroundColor' );
+ if ( ref && background ) {
+ ref.style.setProperty(
+ '--wc-dropdown-background',
+ background
+ );
+ }
+ },
+ },
},
{ lock: true }
);
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/index.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/index.tsx
index fda89c64619..6ec701200b1 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/index.tsx
@@ -3,6 +3,7 @@
*/
import { productFilterOptions } from '@woocommerce/icons';
import { registerBlockType } from '@wordpress/blocks';
+import type { BlockAttributes, BlockConfiguration } from '@wordpress/blocks';
/**
* Internal dependencies
@@ -10,7 +11,7 @@ import { registerBlockType } from '@wordpress/blocks';
import metadata from './block.json';
import Edit from './edit';
-registerBlockType( metadata, {
+registerBlockType( metadata as BlockConfiguration< BlockAttributes >, {
edit: Edit,
icon: productFilterOptions,
save: () => null,
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/style.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/style.scss
index b959a272bed..277085c9563 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/style.scss
@@ -1,3 +1,79 @@
+@import "../product-filters/inner-blocks/checkbox-list/style";
+
:where(.wc-block-dropdown__fieldset) {
display: contents;
}
+
+.wc-block-dropdown--multiple {
+ position: relative;
+ width: 100%;
+}
+
+:where(.wc-block-dropdown__trigger) {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75em;
+ width: 100%;
+ padding: 0.625em 0.75em;
+ border: 1px solid currentColor;
+ border-radius: 2px;
+ font: inherit;
+ text-align: start;
+ color: inherit;
+ background: var(--wc-dropdown-background, Canvas);
+ cursor: pointer;
+
+ &:focus-visible {
+ outline: 2px solid currentColor;
+ outline-offset: -3px;
+ }
+}
+
+.wc-block-dropdown__summary {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.wc-block-dropdown__chevron {
+ flex-shrink: 0;
+ width: 0.5em;
+ height: 0.5em;
+ border-inline-end: 1px solid currentColor;
+ border-bottom: 1px solid currentColor;
+ transform: translateY(-25%) rotate(45deg);
+
+ [aria-expanded="true"] & {
+ transform: translateY(25%) rotate(225deg);
+ }
+}
+
+:where(.wc-block-dropdown__panel) {
+ position: absolute;
+ inset-inline: 0;
+ top: 100%;
+ z-index: 10;
+ box-sizing: border-box;
+ max-height: 16em;
+ overflow-y: auto;
+ padding: 0.625em 0.75em;
+ border: 1px solid currentColor;
+ border-top: 0;
+ border-radius: 0 0 2px 2px;
+ background: var(--wc-dropdown-background, Canvas);
+ box-shadow: 0 4px 8px #0000001a;
+
+ .wc-block-product-filter-checkbox-list__item + .wc-block-product-filter-checkbox-list__item {
+ margin-block-start: 0.5em;
+ }
+}
+
+.wc-block-dropdown__panel input[type="checkbox"].wc-block-product-filter-checkbox-list__input {
+ border-color: currentColor;
+
+ &:focus-visible {
+ outline: 2px solid currentColor;
+ outline-offset: 2px;
+ }
+}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/utils.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/utils.ts
new file mode 100644
index 00000000000..839be5ec27b
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/utils.ts
@@ -0,0 +1,21 @@
+/**
+ * External dependencies
+ */
+import type { SelectableItem } from '@woocommerce/types';
+
+export function getSelectionSummary(
+ items: readonly SelectableItem[],
+ placeholder: string
+): string {
+ const selected = items.filter( ( item ) => item.selected );
+ if ( ! selected.length ) {
+ return placeholder;
+ }
+ const labels = selected
+ .slice( 0, 2 )
+ .map( ( item ) => item.label )
+ .join( ', ' );
+ return selected.length > 2
+ ? `${ labels } +${ selected.length - 2 }`
+ : labels;
+}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/__tests__/frontend.test.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/__tests__/frontend.test.ts
index 45a97d33510..47e884fc9c9 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/__tests__/frontend.test.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/__tests__/frontend.test.ts
@@ -129,6 +129,7 @@ describe( 'product filters interactivity store', () => {
{
value: 'blue',
type: 'attribute/color',
+ label: 'blue',
activeLabel: 'Color: blue',
},
] );
@@ -163,6 +164,7 @@ describe( 'product filters interactivity store', () => {
{
value: 'gray',
type: 'attribute/color',
+ label: 'Gray',
activeLabel: 'Color: Gray',
attributeQueryType: 'or',
},
@@ -178,6 +180,44 @@ describe( 'product filters interactivity store', () => {
expect( mockRegisteredStore.state.params ).toEqual( {} );
} );
+ it( 'retains unavailable selections and labels from existing filter payloads', () => {
+ if ( ! mockRegisteredStore ) {
+ throw new Error( 'Product filters store was not registered.' );
+ }
+ mockGetContext.mockReturnValue( {
+ filterType: 'attribute/color',
+ activeFilters: [
+ {
+ type: 'attribute/color',
+ value: 'blue',
+ activeLabel: 'Color: Blue',
+ },
+ {
+ type: 'attribute/color',
+ value: 'yellow',
+ label: 'Yellow',
+ activeLabel: 'Color: Yellow',
+ },
+ {
+ type: 'status',
+ value: 'instock',
+ label: 'In stock',
+ activeLabel: 'Availability: In stock',
+ },
+ ],
+ } );
+ mockGetServerContext.mockReturnValue( {
+ items: [
+ { type: 'attribute/color', value: 'blue', label: 'Blue' },
+ ],
+ } );
+ expect(
+ mockRegisteredStore.state.selectedItems.map(
+ ( item ) => item.label
+ )
+ ).toEqual( [ 'Blue', 'Yellow' ] );
+ } );
+
it( 'returns no selectable items when server context items are not an array', () => {
if ( ! mockRegisteredStore ) {
throw new Error( 'Product filters store was not registered.' );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/frontend.ts
index 1c613637878..eed4cff6f49 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/frontend.ts
@@ -62,6 +62,7 @@ function selectFilter( item: ValidFilterOptionItem ) {
const newActiveFilter: ActiveFilterItem = {
value: item.value,
type: item.type,
+ label: getFilterLabel( item ),
activeLabel: context.activeLabelTemplate.replace(
'{{label}}',
getFilterLabel( item )
@@ -161,6 +162,23 @@ const productFiltersStore = {
uid: `${ item.type }/${ item.value }`,
} ) );
},
+ get selectedItems(): FilterOptionItem[] {
+ const { filterType } = getContext< ProductFiltersContext >();
+ return state.activeFilters
+ .filter( ( item ) => item.type === filterType )
+ .map( ( item ) => ( {
+ ...item,
+ id: `${ item.type }-${ item.value }`,
+ label: decodeHtmlEntities(
+ item.label ??
+ state.selectableItems.find(
+ ( option ) => option.value === item.value
+ )?.label ??
+ item.activeLabel
+ ),
+ selected: true,
+ } ) );
+ },
get selectableItems() {
// Items are server-owned (narrow on every navigation); read
// from server context so they refresh post-navigation.
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 bcf9127ce7d..eb5e28c0a12 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
@@ -31,6 +31,7 @@ export type ActiveFilterItem = {
type: string;
value: string;
attributeQueryType?: 'and' | 'or';
+ label?: string;
activeLabel: string;
};
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/Dropdown.php b/plugins/woocommerce/src/Blocks/BlockTypes/Dropdown.php
index fc6bc1c7810..1c4fd8842e1 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/Dropdown.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/Dropdown.php
@@ -3,8 +3,10 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Blocks\BlockTypes;
+use Automattic\WooCommerce\Internal\ProductAttributes\VisualAttributeTermMeta;
+
/**
- * Dropdown block (native select).
+ * Dropdown block for single or multiple selection.
*/
final class Dropdown extends AbstractBlock {
@@ -48,12 +50,49 @@ final class Dropdown extends AbstractBlock {
$selectable_items = $block->context['woocommerce/selectableItems'];
$items = is_array( $selectable_items['items'] ?? null ) ? $selectable_items['items'] : array();
- $store_namespace = is_string( $selectable_items['storeNamespace'] ?? null ) ? $selectable_items['storeNamespace'] : 'woocommerce/add-to-cart-with-options';
if ( empty( $items ) ) {
return '';
}
+ $styles = $attributes['style'] ?? array();
+ foreach ( array(
+ 'textColor' => 'text',
+ 'backgroundColor' => 'background',
+ ) as $attribute => $property ) {
+ if ( ! empty( $attributes[ $attribute ] ) ) {
+ $styles['color'][ $property ] = 'var:preset|color|' . $attributes[ $attribute ];
+ }
+ }
+ if ( ! empty( $attributes['borderColor'] ) ) {
+ $styles['border']['color'] = 'var:preset|color|' . $attributes['borderColor'];
+ }
+ $field_props = wp_parse_args(
+ wp_style_engine_get_styles( $styles ),
+ array(
+ 'css' => '',
+ 'classnames' => '',
+ )
+ );
+
+ if ( 'multiple' === ( $selectable_items['selectionMode'] ?? 'single' ) ) {
+ return $this->render_multiple( $selectable_items, $field_props );
+ }
+
+ return $this->render_single( $selectable_items, $block, $field_props );
+ }
+
+ /**
+ * Render a native single-selection dropdown.
+ *
+ * @param array $selectable_items Selectable items context.
+ * @param \WP_Block $block Block instance.
+ * @param array $field_props Field classes and styles.
+ * @return string
+ */
+ private function render_single( array $selectable_items, \WP_Block $block, array $field_props ): string {
+ $items = $selectable_items['items'];
+ $store_namespace = is_string( $selectable_items['storeNamespace'] ?? null ) ? $selectable_items['storeNamespace'] : 'woocommerce/add-to-cart-with-options';
$attribute_id = $block->context['woocommerce/attributeId'] ?? '';
$has_external_label = is_string( $attribute_id ) && '' !== $attribute_id;
$select_id = $has_external_label
@@ -81,7 +120,8 @@ final class Dropdown extends AbstractBlock {
<legend class="screen-reader-text"><?php echo esc_html( __( 'Choose an option', 'woocommerce' ) ); ?></legend>
<?php endif; ?>
<select
- class="wc-block-dropdown__select"
+ class="<?php echo esc_attr( trim( 'wc-block-dropdown__select ' . $field_props['classnames'] ) ); ?>"
+ style="<?php echo esc_attr( $field_props['css'] ); ?>"
id="<?php echo esc_attr( $select_id ); ?>"
<?php if ( $has_external_label ) : ?>
aria-labelledby="<?php echo esc_attr( $attribute_id . '_label' ); ?>"
@@ -146,4 +186,123 @@ final class Dropdown extends AbstractBlock {
$output = ob_get_clean();
return is_string( $output ) ? $output : '';
}
+
+ /**
+ * Summarize the first two selected labels and the remaining count.
+ *
+ * @param array $items Selectable items.
+ * @return string
+ */
+ private function get_selection_summary( array $items ): string {
+ $selected = array_values( array_filter( $items, static fn( $item ) => ! empty( $item['selected'] ) ) );
+ $summary = implode( ', ', array_map( array( $this, 'get_option_text' ), array_slice( $selected, 0, 2 ) ) );
+ if ( count( $selected ) > 2 ) {
+ return $summary . ' +' . ( count( $selected ) - 2 );
+ }
+ return '' !== $summary ? $summary : __( 'Choose options', 'woocommerce' );
+ }
+
+ /**
+ * Render a checkbox dropdown without changing the parent's selection behavior.
+ *
+ * @param array $selectable_items Selectable items context.
+ * @param array $field_props Field classes and styles.
+ * @return string
+ */
+ private function render_multiple( array $selectable_items, array $field_props ): string {
+ $items = $selectable_items['items'];
+ $id = wp_unique_id( 'wc-block-dropdown-' );
+
+ wp_interactivity_state(
+ 'woocommerce/dropdown',
+ array(
+ 'triggerStyle' => static function () {
+ $context = wp_interactivity_get_context();
+ return $context['fieldStyle'] . ( $context['isOpen'] ? 'border-bottom-left-radius:0;border-bottom-right-radius:0;' : '' );
+ },
+ 'items' => static function () {
+ return wp_interactivity_get_context()['items'];
+ },
+ 'selectionSummary' => function () {
+ $context = wp_interactivity_get_context();
+ $parent = wp_interactivity_get_context( $context['storeNamespace'] );
+ $items = $context['items'];
+ if ( isset( $parent['activeFilters'], $parent['filterType'] ) ) {
+ $labels = array_column( $items, 'label', 'value' );
+ $items = array_map(
+ static fn( $item ) => array(
+ 'label' => $item['label'] ?? $labels[ $item['value'] ] ?? $item['activeLabel'],
+ 'selected' => true,
+ ),
+ array_filter( $parent['activeFilters'], static fn( $item ) => $item['type'] === $parent['filterType'] )
+ );
+ }
+ return $this->get_selection_summary( $items );
+ },
+ 'colorSwatchStyle' => static function () {
+ return VisualAttributeTermMeta::get_swatch_style( wp_interactivity_get_context()['item']['visual'] ?? array() );
+ },
+ 'isColorSwatchEmpty' => static function () {
+ return empty( wp_interactivity_get_context()['item']['visual']['value'] );
+ },
+ )
+ );
+
+ $wrapper_attributes = array(
+ 'class' => 'wc-block-dropdown wc-block-dropdown--multiple',
+ 'data-wp-interactive' => 'woocommerce/dropdown',
+ 'data-wp-on--keydown' => 'actions.onKeydown',
+ 'data-wp-on--focusout' => 'actions.closeOnFocusOutside',
+ 'data-wp-on-document--click' => 'actions.closeOnClickOutside',
+ 'data-wp-init' => 'callbacks.initColors',
+ 'data-wp-context' => (string) wp_json_encode(
+ array(
+ 'storeNamespace' => $selectable_items['storeNamespace'],
+ 'placeholder' => __( 'Choose options', 'woocommerce' ),
+ 'items' => $items,
+ 'isOpen' => false,
+ 'fieldStyle' => $field_props['css'],
+ ),
+ JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP
+ ),
+ );
+ $has_visual = count( array_filter( $items, static fn( $item ) => isset( $item['visual'] ) ) ) > 0;
+ $has_counts = count( array_filter( $items, static fn( $item ) => isset( $item['count'] ) ) ) > 0;
+
+ ob_start();
+ ?>
+ <div <?php echo get_block_wrapper_attributes( $wrapper_attributes ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>>
+ <button type="button" class="<?php echo esc_attr( trim( 'wc-block-dropdown__trigger ' . $field_props['classnames'] ) ); ?>" style="<?php echo esc_attr( $field_props['css'] ); ?>" data-wp-bind--style="state.triggerStyle" id="<?php echo esc_attr( $id ); ?>" aria-expanded="false" data-wp-bind--aria-expanded="context.isOpen" aria-controls="<?php echo esc_attr( $id . '-options' ); ?>" data-wp-on--click="actions.togglePanel">
+ <span class="screen-reader-text"><?php echo esc_html( ( $selectable_items['groupLabel'] ?? __( 'Filter options', 'woocommerce' ) ) . ': ' ); ?></span>
+ <span class="wc-block-dropdown__summary" data-wp-text="state.selectionSummary"><?php echo esc_html( $this->get_selection_summary( $items ) ); ?></span>
+ <span class="wc-block-dropdown__chevron" aria-hidden="true"></span>
+ </button>
+ <div class="<?php echo esc_attr( trim( 'wc-block-dropdown__panel wc-block-product-filter-checkbox-list ' . $field_props['classnames'] ) ); ?>" style="<?php echo esc_attr( $field_props['css'] . 'border-top-width:0;border-top-left-radius:0;border-top-right-radius:0;' ); ?>" id="<?php echo esc_attr( $id . '-options' ); ?>" hidden data-wp-bind--hidden="!context.isOpen">
+ <fieldset>
+ <legend class="screen-reader-text"><?php echo esc_html( $selectable_items['groupLabel'] ?? __( 'Filter options', 'woocommerce' ) ); ?></legend>
+ <template data-wp-each--item="state.items" data-wp-each-key="context.item.id">
+ <div class="wc-block-product-filter-checkbox-list__item" data-wp-bind--hidden="context.item.hidden">
+ <label class="wc-block-product-filter-checkbox-list__label">
+ <span class="wc-block-product-filter-checkbox-list__input-wrapper">
+ <input type="checkbox" class="wc-block-product-filter-checkbox-list__input" data-wp-bind--value="context.item.value" data-wp-bind--checked="context.item.selected" data-wp-bind--disabled="context.item.disabled" data-wp-on--change="actions.toggleItem" />
+ <svg class="wc-block-product-filter-checkbox-list__mark" viewBox="0 0 10 8" fill="none" aria-hidden="true"><path d="M9.25 1.19922L3.75 6.69922L1 3.94922" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
+ </span>
+ <span class="wc-block-product-filter-checkbox-list__text-wrapper">
+ <?php if ( $has_visual ) : ?>
+ <span class="wc-block-product-filter-checkbox-list__color-swatch" data-wp-bind--style="state.colorSwatchStyle" data-wp-class--is-empty="state.isColorSwatchEmpty" aria-hidden="true"></span>
+ <?php endif; ?>
+ <span class="wc-block-product-filter-checkbox-list__text" data-wp-text="context.item.label"></span>
+ <?php if ( $has_counts ) : ?>
+ <span class="wc-block-product-filter-checkbox-list__count">(<span data-wp-text="context.item.count"></span>)</span>
+ <?php endif; ?>
+ </span>
+ </label>
+ </div>
+ </template>
+ </fieldset>
+ </div>
+ </div>
+ <?php
+ return (string) ob_get_clean();
+ }
}
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php
index cadce01c840..28a36d58a3a 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php
@@ -125,6 +125,7 @@ final class ProductFilterAttribute extends AbstractBlock {
$items[] = array(
'type' => 'attribute/' . $attribute_name,
'value' => $term_object->slug,
+ 'label' => $term_name,
'activeLabel' => sprintf( '%s: %s', $product_attributes_map[ $attribute_name ], $term_name ),
'attributeQueryType' => $query_types[ $attribute_name ] ?? 'or',
);
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterStatus.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterStatus.php
index 57bcb0d4b2d..e70698f2b33 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterStatus.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterStatus.php
@@ -68,6 +68,7 @@ final class ProductFilterStatus extends AbstractBlock {
$items[] = array(
'type' => 'status',
'value' => $status,
+ 'label' => $status_options[ $status ],
// translators: %s: availability.
'activeLabel' => sprintf( __( 'Availability: %s', 'woocommerce' ), $status_options[ $status ] ),
);
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterTaxonomy.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterTaxonomy.php
index 0091e413de4..a3265e75f16 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterTaxonomy.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterTaxonomy.php
@@ -72,6 +72,7 @@ final class ProductFilterTaxonomy extends AbstractBlock {
$items[] = array(
'type' => 'taxonomy/' . $term->taxonomy,
'value' => $term->slug,
+ 'label' => $term_name,
'activeLabel' => $taxonomy_object->labels->singular_name . ': ' . $term_name,
);
}//end if
diff --git a/plugins/woocommerce/tests/e2e/content-templates/blocks/template_archive-product_attribute-filter.handlebars b/plugins/woocommerce/tests/e2e/content-templates/blocks/template_archive-product_attribute-filter.handlebars
index 2c1a1771386..4fb81f15a2f 100644
--- a/plugins/woocommerce/tests/e2e/content-templates/blocks/template_archive-product_attribute-filter.handlebars
+++ b/plugins/woocommerce/tests/e2e/content-templates/blocks/template_archive-product_attribute-filter.handlebars
@@ -18,11 +18,22 @@
<!-- /wp:heading --></div>
<!-- /wp:group -->
+{{#if dropdown}}
+{{#> wp-block blockName='woocommerce/dropdown' attributes=dropdownAttributes }}{{/ wp-block }}
+{{else}}
<!-- wp:woocommerce/product-filter-checkbox-list {"lock":{"remove":true}} -->
<div class="wp-block-woocommerce-product-filter-checkbox-list wc-block-product-filter-checkbox-list"></div>
-<!-- /wp:woocommerce/product-filter-checkbox-list --></div>
+<!-- /wp:woocommerce/product-filter-checkbox-list -->
+{{/if}}
+</div>
{{/ wp-block }}
+{{#if taxonomy}}
+<!-- wp:woocommerce/product-filter-taxonomy {"taxonomy":"product_cat","displayStyle":"woocommerce/dropdown"} -->
+<div class="wp-block-woocommerce-product-filter-taxonomy"><!-- wp:woocommerce/dropdown /--></div>
+<!-- /wp:woocommerce/product-filter-taxonomy -->
+{{/if}}
+
</div>
<!-- /wp:woocommerce/product-filters -->
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/product-filters/attribute-filter-editor.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/product-filters/attribute-filter-editor.block_theme.spec.ts
index a771857d59a..18eee81039a 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/product-filters/attribute-filter-editor.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/product-filters/attribute-filter-editor.block_theme.spec.ts
@@ -1,7 +1,12 @@
/**
* External dependencies
*/
-import { test as base, expect, BLOCK_THEME_SLUG } from '@woocommerce/e2e-utils';
+import {
+ test as base,
+ expect,
+ BLOCK_THEME_SLUG,
+ getProductAttributeIds,
+} from '@woocommerce/e2e-utils';
/**
* Internal dependencies
@@ -74,4 +79,69 @@ test.describe( `${ blockData.name }`, () => {
).toBeVisible();
}
} );
+
+ test( 'Dropdown inherits and updates the ancestor background without explicit colors', async ( {
+ editor,
+ page,
+ } ) => {
+ const { colorAttributeId } = await getProductAttributeIds();
+ await editor.setContent( '' );
+ await editor.insertBlock( {
+ name: 'core/group',
+ attributes: {
+ style: { color: { background: '#111111', text: '#ffffff' } },
+ },
+ innerBlocks: [
+ {
+ name: 'woocommerce/product-filters',
+ attributes: { overlayMode: 'off' },
+ innerBlocks: [
+ {
+ name: 'woocommerce/product-filter-attribute',
+ attributes: {
+ attributeId: colorAttributeId,
+ displayStyle: 'woocommerce/dropdown',
+ },
+ innerBlocks: [ { name: 'woocommerce/dropdown' } ],
+ },
+ ],
+ },
+ ],
+ } );
+ const dropdown = editor.canvas.getByLabel( 'Block: Dropdown', {
+ exact: true,
+ } );
+ await editor.selectBlocks( dropdown );
+ const trigger = dropdown.locator( '.wc-block-dropdown__trigger' );
+ const panel = dropdown.locator( '.wc-block-dropdown__panel' );
+ await expect( trigger ).toHaveCSS( 'color', 'rgb(255, 255, 255)' );
+ await expect( trigger ).toHaveCSS(
+ 'background-color',
+ 'rgb(17, 17, 17)'
+ );
+ await expect( panel ).toHaveCSS(
+ 'background-color',
+ 'rgb(17, 17, 17)'
+ );
+ await trigger.focus();
+ await expect( trigger ).not.toBeFocused();
+ await page.evaluate( () => {
+ const { select, dispatch } = window.wp.data;
+ const group = select( 'core/block-editor' )
+ .getBlocks()
+ .find( ( block ) => block.name === 'core/group' );
+ dispatch( 'core/block-editor' ).updateBlockAttributes(
+ group.clientId,
+ { style: { color: { background: '#284b63', text: '#ffffff' } } }
+ );
+ } );
+ await expect( trigger ).toHaveCSS(
+ 'background-color',
+ 'rgb(40, 75, 99)'
+ );
+ await expect( panel ).toHaveCSS(
+ 'background-color',
+ 'rgb(40, 75, 99)'
+ );
+ } );
} );
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/product-filters/attribute-filter-frontend.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/product-filters/attribute-filter-frontend.block_theme.spec.ts
index 184e894100d..0e4c08fa996 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/product-filters/attribute-filter-frontend.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/product-filters/attribute-filter-frontend.block_theme.spec.ts
@@ -115,6 +115,339 @@ test.describe( 'woocommerce/product-filter-attribute - Frontend', () => {
} );
} );
+ test( 'Dropdown updates results on each checkbox selection and supports keyboard dismissal', async ( {
+ templateCompiler,
+ page,
+ } ) => {
+ const { colorAttributeId } = await getProductAttributeIds();
+ await templateCompiler.compile( {
+ dropdown: true,
+ attributes: {
+ attributeId: colorAttributeId,
+ displayStyle: 'woocommerce/dropdown',
+ showCounts: true,
+ sortOrder: 'name-asc',
+ },
+ } );
+ await page.goto( '/shop' );
+ const trigger = page.getByRole( 'button', {
+ name: 'Color: Choose options',
+ } );
+ const productTitles = page.locator(
+ '.wp-block-woocommerce-product-template .wp-block-post-title'
+ );
+ await trigger.focus();
+ await page.keyboard.press( 'Enter' );
+ await page.keyboard.press( 'Tab' );
+ const blue = page.getByRole( 'checkbox', { name: 'Blue (4)' } );
+ await expect( blue ).toBeFocused();
+ await page.keyboard.press( 'Space' );
+ await expect( productTitles ).toHaveText( [
+ 'Hoodie',
+ 'Hoodie with Logo',
+ 'Polo',
+ 'V-Neck T-Shirt',
+ ] );
+ await expect( blue ).toBeFocused();
+ await page.getByRole( 'checkbox', { name: 'Gray (2)' } ).check();
+ await expect( productTitles ).toHaveText( [
+ 'Hoodie',
+ 'Hoodie with Logo',
+ 'Polo',
+ ...GRAY_PRODUCT_TITLES,
+ 'V-Neck T-Shirt',
+ ] );
+ const selectedTrigger = page.getByRole( 'button', {
+ name: 'Color: Blue, Gray',
+ exact: true,
+ } );
+ await expect( selectedTrigger ).toHaveAttribute(
+ 'aria-expanded',
+ 'true'
+ );
+ await page.keyboard.press( 'Escape' );
+ await expect( selectedTrigger ).toBeFocused();
+ await expect( selectedTrigger ).toHaveAttribute(
+ 'aria-expanded',
+ 'false'
+ );
+ await page.waitForURL(
+ ( url ) =>
+ url.searchParams.get( 'filter_color' )?.includes( 'gray' ) ??
+ false
+ );
+ await page.reload();
+ await expect( selectedTrigger ).toBeVisible();
+ await selectedTrigger.click();
+ await expect( blue ).toBeChecked();
+ await expect(
+ page.getByRole( 'checkbox', { name: 'Gray (2)' } )
+ ).toBeChecked();
+ await page.getByRole( 'checkbox', { name: 'Gray (2)' } ).uncheck();
+ await expect( productTitles ).toHaveText( [
+ 'Hoodie',
+ 'Hoodie with Logo',
+ 'Polo',
+ 'V-Neck T-Shirt',
+ ] );
+ } );
+
+ test( 'Escape dismisses the Dropdown before the filter overlay', async ( {
+ templateCompiler,
+ page,
+ } ) => {
+ const { colorAttributeId } = await getProductAttributeIds();
+ await templateCompiler.compile( {
+ dropdown: true,
+ attributes: {
+ attributeId: colorAttributeId,
+ displayStyle: 'woocommerce/dropdown',
+ },
+ } );
+ await page.setViewportSize( { width: 390, height: 844 } );
+ await page.goto( '/shop' );
+ await page.getByRole( 'button', { name: 'Filter products' } ).click();
+ const overlay = page.getByRole( 'dialog', { name: 'Product Filters' } );
+ const trigger = overlay.getByRole( 'button', {
+ name: 'Color: Choose options',
+ } );
+ await trigger.click();
+ await page.keyboard.press( 'Tab' );
+ await expect(
+ overlay.getByRole( 'checkbox', { name: 'Blue', exact: true } )
+ ).toBeFocused();
+ await page.keyboard.down( 'Escape' );
+ await page.keyboard.up( 'Escape' );
+ await expect( trigger ).toHaveAttribute( 'aria-expanded', 'false' );
+ await page.evaluate(
+ () =>
+ new Promise( ( resolve ) =>
+ requestAnimationFrame( () =>
+ requestAnimationFrame( resolve )
+ )
+ )
+ );
+ await expect(
+ page.locator( '.wp-block-woocommerce-product-filters' )
+ ).toHaveClass( /\bis-overlay-opened\b/ );
+ await expect( overlay ).toBeVisible();
+ await expect( trigger ).toBeFocused();
+ await page.keyboard.press( 'Escape' );
+ await expect( overlay ).toHaveCount( 0 );
+ } );
+
+ test( 'Dropdown summary retains active selections hidden by another filter', async ( {
+ templateCompiler,
+ page,
+ browser,
+ } ) => {
+ const { colorAttributeId } = await getProductAttributeIds();
+ await templateCompiler.compile( {
+ dropdown: true,
+ taxonomy: true,
+ attributes: {
+ attributeId: colorAttributeId,
+ displayStyle: 'woocommerce/dropdown',
+ hideEmpty: true,
+ },
+ } );
+ await page.goto( '/shop' );
+ const color = page.locator(
+ '.wp-block-woocommerce-product-filter-attribute'
+ );
+ const trigger = color.getByRole( 'button' );
+ await trigger.click();
+ await color
+ .getByRole( 'checkbox', { name: 'Blue', exact: true } )
+ .check();
+ await color
+ .getByRole( 'checkbox', { name: 'Yellow', exact: true } )
+ .check();
+ await page.keyboard.press( 'Escape' );
+ const category = page.locator(
+ '.wp-block-woocommerce-product-filter-taxonomy'
+ );
+ await category.getByRole( 'button' ).click();
+ await category
+ .getByRole( 'checkbox', { name: 'Hoodies', exact: true } )
+ .check();
+ await expect(
+ page.locator(
+ '.wp-block-woocommerce-product-template .wp-block-post-title'
+ )
+ ).toHaveText( [ 'Hoodie', 'Hoodie with Logo' ] );
+ await expect( trigger ).toHaveText( 'Color: Blue, Yellow' );
+ await page.reload();
+ await expect( trigger ).toHaveText( 'Color: Blue, Yellow' );
+ await trigger.click();
+ await expect(
+ color.getByRole( 'checkbox', { name: 'Yellow', exact: true } )
+ ).toHaveCount( 0 );
+ await color
+ .getByRole( 'checkbox', { name: 'Green', exact: true } )
+ .check();
+ await expect( trigger ).toHaveText( 'Color: Blue, Green +1' );
+ await page.waitForURL(
+ ( url ) =>
+ url.searchParams.get( 'filter_color' )?.includes( 'green' ) ??
+ false
+ );
+ const noScript = await browser.newContext( {
+ javaScriptEnabled: false,
+ } );
+ try {
+ const serverPage = await noScript.newPage();
+ await serverPage.goto( page.url() );
+ await expect(
+ serverPage.getByRole( 'button', {
+ name: 'Color: Blue, Green +1',
+ exact: true,
+ } )
+ ).toBeVisible();
+ } finally {
+ await noScript.close();
+ }
+ } );
+
+ test( 'Dropdown defaults allow theme CSS overrides in either load order', async ( {
+ templateCompiler,
+ page,
+ } ) => {
+ const { colorAttributeId } = await getProductAttributeIds();
+ const attributes = {
+ attributeId: colorAttributeId,
+ displayStyle: 'woocommerce/dropdown',
+ };
+ await templateCompiler.compile( { dropdown: true, attributes } );
+ await page.goto( '/shop' );
+ const trigger = page.getByRole( 'button', {
+ name: 'Color: Choose options',
+ } );
+ const panel = page.locator( '.wc-block-dropdown__panel' );
+ const themeStyle = await page.addStyleTag( {
+ content:
+ '.wc-block-dropdown__trigger, .wc-block-dropdown__panel { border: 3px dashed purple; border-radius: 10px; }',
+ } );
+ await expect( trigger ).toHaveCSS( 'border-top-width', '3px' );
+ await expect( trigger ).toHaveCSS( 'border-top-style', 'dashed' );
+ await expect( trigger ).toHaveCSS( 'border-top-left-radius', '10px' );
+ await trigger.click();
+ await expect( trigger ).toHaveCSS( 'border-bottom-left-radius', '0px' );
+ await expect( panel ).toHaveCSS( 'border-bottom-left-radius', '10px' );
+ await expect( panel ).toHaveCSS( 'border-top-width', '0px' );
+ await page.keyboard.press( 'Escape' );
+ await expect( trigger ).toHaveCSS(
+ 'border-bottom-left-radius',
+ '10px'
+ );
+ await themeStyle.evaluate( ( style ) =>
+ document.head.prepend( style )
+ );
+ await expect( trigger ).toHaveCSS( 'border-top-width', '3px' );
+ await expect( trigger ).toHaveCSS( 'border-top-left-radius', '10px' );
+ } );
+
+ test( 'Dropdown Core colors and borders override theme CSS and preserve separate corners', async ( {
+ templateCompiler,
+ page,
+ } ) => {
+ const { colorAttributeId } = await getProductAttributeIds();
+ await templateCompiler.compile( {
+ dropdown: true,
+ attributes: {
+ attributeId: colorAttributeId,
+ displayStyle: 'woocommerce/dropdown',
+ },
+ dropdownAttributes: {
+ style: {
+ color: { text: '#f8fafc', background: '#172554' },
+ border: {
+ color: '#15803d',
+ width: '0px',
+ style: 'solid',
+ radius: {
+ topLeft: '3px',
+ topRight: '7px',
+ bottomRight: '11px',
+ bottomLeft: '17px',
+ },
+ },
+ },
+ },
+ } );
+ await page.goto( '/shop' );
+ const trigger = page.getByRole( 'button', {
+ name: /^Color:/,
+ } );
+ const panel = page.locator( '.wc-block-dropdown__panel' );
+ await page.addStyleTag( {
+ content:
+ '.wc-block-dropdown__trigger, .wc-block-dropdown__panel { border: 3px dashed purple; border-radius: 10px; }',
+ } );
+ await expect( trigger ).toHaveCSS( 'border-top-width', '0px' );
+ await expect( trigger ).toHaveCSS(
+ 'border-top-color',
+ 'rgb(21, 128, 61)'
+ );
+ await expect( trigger ).toHaveCSS( 'color', 'rgb(248, 250, 252)' );
+ await expect( trigger ).toHaveCSS(
+ 'background-color',
+ 'rgb(23, 37, 84)'
+ );
+ await expect( trigger ).toHaveCSS( 'border-top-left-radius', '3px' );
+ await expect( trigger ).toHaveCSS( 'border-top-right-radius', '7px' );
+ await expect( trigger ).toHaveCSS(
+ 'border-bottom-right-radius',
+ '11px'
+ );
+ await expect( trigger ).toHaveCSS(
+ 'border-bottom-left-radius',
+ '17px'
+ );
+ await trigger.click();
+ const listStyle = await page.addStyleTag( {
+ path: `${
+ process.env.WC_SHARED_PLUGIN_BUILD_PATH || '.'
+ }/assets/client/blocks/woocommerce/product-filter-checkbox-list-style.css`,
+ } );
+ const blue = panel.getByRole( 'checkbox', {
+ name: 'Blue',
+ exact: true,
+ } );
+ await expect( blue ).toHaveCSS(
+ 'border-top-color',
+ 'rgb(248, 250, 252)'
+ );
+ await listStyle.evaluate( ( style ) => document.head.prepend( style ) );
+ await expect( blue ).toHaveCSS(
+ 'border-top-color',
+ 'rgb(248, 250, 252)'
+ );
+ await expect( panel ).toHaveCSS( 'color', 'rgb(248, 250, 252)' );
+ await expect( panel ).toHaveCSS(
+ 'background-color',
+ 'rgb(23, 37, 84)'
+ );
+ await expect(
+ panel.getByRole( 'checkbox', { name: 'Blue', exact: true } )
+ ).toHaveCSS( 'border-top-color', 'rgb(248, 250, 252)' );
+ await page
+ .getByRole( 'checkbox', { name: 'Blue', exact: true } )
+ .check();
+ await expect( trigger ).toHaveText( 'Color: Blue' );
+ await expect( trigger ).toHaveCSS(
+ 'background-color',
+ 'rgb(23, 37, 84)'
+ );
+ await expect( panel ).toHaveCSS( 'border-bottom-left-radius', '17px' );
+ await expect( panel ).toHaveCSS( 'border-bottom-right-radius', '11px' );
+ await page.keyboard.press( 'Escape' );
+ await expect( trigger ).toHaveCSS(
+ 'border-bottom-left-radius',
+ '17px'
+ );
+ } );
+
test.describe( 'With show counts enabled', () => {
test.beforeEach( async ( { templateCompiler } ) => {
const { colorAttributeId } = await getProductAttributeIds();