Commit c9f3967183f for woocommerce
commit c9f3967183fe8937ba398c8f5547ddd95bdb9774
Author: Luigi Teschio <gigitux@gmail.com>
Date: Mon May 18 09:00:13 2026 +0200
Update product taxonomy fields to use chip select (#64921)
* Add tree select control to products app
* WIP
* Update taxonomy fields to use chip select
* Add changelog entry for taxonomy chip select
* Replace tree select control with searchable chip select
* fix build
* lint code
* fix lint errors
diff --git a/packages/js/experimental-products-app/changelog/update-taxonomy-fields-chip-select b/packages/js/experimental-products-app/changelog/update-taxonomy-fields-chip-select
new file mode 100644
index 00000000000..bec6e1bac98
--- /dev/null
+++ b/packages/js/experimental-products-app/changelog/update-taxonomy-fields-chip-select
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Use the searchable chip select control for product category, tag, and brand taxonomy fields in the experimental products app.
diff --git a/packages/js/experimental-products-app/package.json b/packages/js/experimental-products-app/package.json
index ae9da6e0565..9d42777f0ab 100644
--- a/packages/js/experimental-products-app/package.json
+++ b/packages/js/experimental-products-app/package.json
@@ -35,6 +35,7 @@
"access": "public"
},
"dependencies": {
+ "@base-ui/react": "1.4.1",
"@dnd-kit/react": "0.4.0",
"@types/lodash": "^4.14.202",
"@woocommerce/data": "workspace:*",
diff --git a/packages/js/experimental-products-app/src/fields/brands/field.tsx b/packages/js/experimental-products-app/src/fields/brands/field.tsx
index ea6482ee4df..b15a414bece 100644
--- a/packages/js/experimental-products-app/src/fields/brands/field.tsx
+++ b/packages/js/experimental-products-app/src/fields/brands/field.tsx
@@ -5,12 +5,13 @@ import { resolveSelect } from '@wordpress/data';
import { store as coreStore } from '@wordpress/core-data';
import { __ } from '@wordpress/i18n';
import { decodeEntities } from '@wordpress/html-entities';
-import type { Field } from '@wordpress/dataviews';
+import type { DataFormControlProps, Field } from '@wordpress/dataviews';
/**
* Internal dependencies
*/
import type { ProductEntityRecord } from '../types';
+import { TaxonomyEdit } from '../components/taxonomy-edit';
const fieldDefinition = {
type: 'array',
@@ -26,6 +27,13 @@ export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
getValue: ( { item } ) => {
return ( item.brands ?? [] ).map( ( { id } ) => id.toString() );
},
+ setValue: ( { value }: { value: string[] } ) => {
+ return {
+ brands: value.map( ( v ) => ( {
+ id: parseInt( v, 10 ),
+ } ) ),
+ };
+ },
render: ( { item } ) => {
const names = ( item.brands ?? [] )
.map( ( { name } ) => decodeEntities( name ?? '' ) )
@@ -48,4 +56,12 @@ export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
label: decodeEntities( name ),
} ) );
},
+ Edit: ( props: DataFormControlProps< ProductEntityRecord > ) => (
+ <TaxonomyEdit
+ { ...props }
+ taxonomy="product_brand"
+ fieldProperty="brands"
+ searchPlaceholder={ __( 'Search or create brands', 'woocommerce' ) }
+ />
+ ),
};
diff --git a/packages/js/experimental-products-app/src/fields/components/taxonomy-edit/index.tsx b/packages/js/experimental-products-app/src/fields/components/taxonomy-edit/index.tsx
index 888c65a7d42..5179da82631 100644
--- a/packages/js/experimental-products-app/src/fields/components/taxonomy-edit/index.tsx
+++ b/packages/js/experimental-products-app/src/fields/components/taxonomy-edit/index.tsx
@@ -1,19 +1,23 @@
/**
* External dependencies
*/
-import type { ReactNode } from 'react';
-import { FormTokenField, Spinner } from '@wordpress/components';
+import { Spinner } from '@wordpress/components';
import { store as coreStore, type Term } from '@wordpress/core-data';
import { dispatch, useDispatch } from '@wordpress/data';
import type { DataFormControlProps } from '@wordpress/dataviews';
import { useCallback, useMemo, useState } from '@wordpress/element';
import { decodeEntities } from '@wordpress/html-entities';
import { __, sprintf } from '@wordpress/i18n';
+import { store as noticesStore } from '@wordpress/notices';
import { Stack } from '@wordpress/ui';
/**
* Internal dependencies
*/
+import {
+ SearchableChipSelectControl,
+ Combobox,
+} from '../searchable-chip-select';
import { useElements } from './use-elements';
import { useAdaptiveTaxonomy } from './use-adaptive-taxonomy';
import type { Item, ItemImage, TaxonomyTermRef } from './types';
@@ -39,8 +43,6 @@ type TaxonomyEditProps< T > = {
termCount?: number;
};
-type TokenValue = string | { value: string };
-
function getErrorMessage( error: unknown ): string {
if ( error instanceof Error ) {
return error.message;
@@ -79,7 +81,7 @@ function isTermRecord( value: unknown ): value is Term {
if ( ! ( 'id' in value ) || ! ( 'name' in value ) ) {
return false;
}
- const term = value as Record< string, unknown >;
+ const term = value as Term;
return typeof term.id === 'number' && typeof term.name === 'string';
}
@@ -87,49 +89,23 @@ function createFieldChange< T extends Record< string, unknown > >(
fieldProperty: keyof T,
value: TaxonomyTermRef[]
): Partial< T > {
- return { [ fieldProperty ]: value } as Partial< T >;
-}
-
-function getTokenValue( token: TokenValue ): string {
- return typeof token === 'string' ? token : token.value;
+ const change: Partial< T > = {};
+ ( change as Record< keyof T, TaxonomyTermRef[] > )[ fieldProperty ] = value;
+ return change;
}
-function normalizeItemLabel( label: string ): string {
- return label.trim().toLocaleLowerCase();
-}
-
-function getHelpContent(
- description: ReactNode,
+function getEmptyContent(
isLoading: boolean,
isServerSearch: boolean,
inputValue: string
) {
- const shouldPromptToSearch = isServerSearch && ! inputValue.trim();
-
- if ( ! description && ! isLoading && ! shouldPromptToSearch ) {
- return null;
+ if ( isLoading ) {
+ return <Spinner />;
}
-
- return (
- <div className="components-base-control__help">
- <Stack direction="column" style={ { gap: '4px' } }>
- { description && <span>{ description }</span> }
- { isLoading && (
- <Stack
- direction="row"
- align="center"
- style={ { gap: '4px' } }
- >
- <Spinner />
- <span>{ __( 'Loading results…', 'woocommerce' ) }</span>
- </Stack>
- ) }
- { shouldPromptToSearch && (
- <span>{ __( 'Type to search…', 'woocommerce' ) }</span>
- ) }
- </Stack>
- </div>
- );
+ if ( isServerSearch && ! inputValue.trim() ) {
+ return __( 'Type to search…', 'woocommerce' );
+ }
+ return __( 'No results found.', 'woocommerce' );
}
export function TaxonomyEdit< T extends Record< string, unknown > >( {
@@ -169,17 +145,7 @@ export function TaxonomyEdit< T extends Record< string, unknown > >( {
} )
.filter( ( item ): item is Item => item !== null );
} );
- const { createErrorNotice } = useDispatch(
- // eslint-disable-next-line @wordpress/data-no-store-string-literals -- @wordpress/notices types are unavailable in this package.
- 'core/notices'
- ) as {
- createErrorNotice: (
- message: string,
- options?: {
- type?: string;
- }
- ) => void | Promise< unknown >;
- };
+ const { createErrorNotice } = useDispatch( noticesStore );
// Legacy mode: load all elements from field definition.
const { elements: fieldItems, isLoading: isFieldLoading } = useElements( {
@@ -219,13 +185,7 @@ export function TaxonomyEdit< T extends Record< string, unknown > >( {
return new Map( items.map( ( item ) => [ item.value, item ] ) );
}, [ items ] );
- const itemsByLabel = useMemo( () => {
- return new Map(
- items.map( ( item ) => [ normalizeItemLabel( item.label ), item ] )
- );
- }, [ items ] );
-
- const selectedItems: Item[] = useMemo( () => {
+ const value: Item[] = useMemo( () => {
const rawRefs = data?.[ fieldProperty ];
const termRefs: TaxonomyTermRef[] = Array.isArray( rawRefs )
? rawRefs.filter( isTaxonomyTermRef )
@@ -235,61 +195,38 @@ export function TaxonomyEdit< T extends Record< string, unknown > >( {
.filter( ( item ): item is Item => item !== undefined );
}, [ data, fieldProperty, itemsMap ] );
- const value = useMemo(
- () => selectedItems.map( ( item ) => item.value ),
- [ selectedItems ]
- );
-
- const suggestions = useMemo(
- () => items.map( ( item ) => item.value ),
- [ items ]
- );
+ const creatableItem: Item | undefined = useMemo( () => {
+ const termName = inputValue.trim();
+ if ( ! termName || isCreating ) {
+ return undefined;
+ }
+ return {
+ value: CREATABLE_VALUE,
+ label: sprintf(
+ /* translators: %s: the name of the new term to create */
+ __( 'Create "%s"', 'woocommerce' ),
+ termName
+ ),
+ };
+ }, [ inputValue, isCreating ] );
const hasImages = useMemo(
() => items.some( ( item ) => item.image?.src ),
[ items ]
);
- const displayTransform = useCallback(
- ( token: string ) => itemsMap.get( token )?.label ?? token,
- [ itemsMap ]
- );
-
- const saveTransform = useCallback(
- ( token: string ) => {
- const trimmedToken = token.trim();
- return itemsMap.get( trimmedToken )?.label ?? trimmedToken;
- },
- [ itemsMap ]
- );
-
const handleValueChange = useCallback(
- async ( tokens: TokenValue[] ) => {
- const tokenValues = tokens.map( getTokenValue );
-
- const createdToken = tokenValues.find(
- ( token ) =>
- ! itemsMap.has( token ) &&
- ! itemsByLabel.has( normalizeItemLabel( token ) )
+ async ( newItems: Item[] ) => {
+ const creatableSelected = newItems.find(
+ ( item ) => item.value === CREATABLE_VALUE
);
- const resolveSelectedItems = () =>
- tokenValues
- .map(
- ( token ) =>
- itemsMap.get( token ) ??
- itemsByLabel.get( normalizeItemLabel( token ) )
- )
- .filter( ( item ): item is Item => item !== undefined );
-
- if ( createdToken ) {
- const termName = createdToken.trim();
+ if ( creatableSelected ) {
+ const termName = inputValue.trim();
if ( ! termName ) {
return;
}
- const resolvedItems = resolveSelectedItems();
-
setIsCreating( true );
try {
@@ -321,7 +258,12 @@ export function TaxonomyEdit< T extends Record< string, unknown > >( {
setKnownItems( ( prev ) => [ ...prev, newItem ] );
- const updatedItems = [ ...resolvedItems, newItem ];
+ const updatedItems = [
+ ...newItems.filter(
+ ( item ) => item.value !== CREATABLE_VALUE
+ ),
+ newItem,
+ ];
onChange(
createFieldChange< T >(
@@ -333,18 +275,10 @@ export function TaxonomyEdit< T extends Record< string, unknown > >( {
setInputValue( '' );
if ( ! isServerSearch ) {
- void (
- dispatch( coreStore ) as unknown as {
- invalidateResolution: (
- selectorName: string,
- args: unknown[]
- ) => void | Promise< unknown >;
- }
- ).invalidateResolution( 'getEntityRecords', [
- 'taxonomy',
- taxonomy,
- { per_page: -1 },
- ] );
+ void dispatch( coreStore ).invalidateResolution(
+ 'getEntityRecords',
+ [ 'taxonomy', taxonomy, { per_page: -1 } ]
+ );
}
}
} catch ( error ) {
@@ -362,14 +296,14 @@ export function TaxonomyEdit< T extends Record< string, unknown > >( {
setIsCreating( false );
}
} else {
- const resolvedItems = resolveSelectedItems();
-
// Track selected items so chips persist across search changes.
if ( isServerSearch ) {
setKnownItems( ( prev ) => {
const known = new Set( prev.map( ( i ) => i.value ) );
- const added = resolvedItems.filter(
- ( item ) => ! known.has( item.value )
+ const added = newItems.filter(
+ ( i ) =>
+ i.value !== CREATABLE_VALUE &&
+ ! known.has( i.value )
);
return added.length > 0 ? [ ...prev, ...added ] : prev;
} );
@@ -378,14 +312,13 @@ export function TaxonomyEdit< T extends Record< string, unknown > >( {
onChange(
createFieldChange< T >(
fieldProperty,
- itemsToTermRefs( resolvedItems )
+ itemsToTermRefs( newItems )
)
);
}
},
[
- itemsByLabel,
- itemsMap,
+ inputValue,
taxonomy,
fieldProperty,
onChange,
@@ -394,70 +327,80 @@ export function TaxonomyEdit< T extends Record< string, unknown > >( {
]
);
- const renderSuggestionItem = useCallback(
- ( { item: token }: { item: string } ) => {
- const suggestionItem = itemsMap.get( token );
-
- if ( ! suggestionItem ) {
- return token;
- }
-
- if ( ! hasImages ) {
- return suggestionItem.label;
- }
-
- return (
- <Stack
- direction="row"
- align="center"
- style={ { gap: '12px' } }
- className="woocommerce-taxonomy-edit__option"
- >
- { suggestionItem.image?.src ? (
- <img
- src={ suggestionItem.image.src }
- alt={ suggestionItem.image.alt ?? '' }
- className="woocommerce-taxonomy-edit__option-thumbnail"
- />
- ) : (
- <span className="woocommerce-taxonomy-edit__option-thumbnail woocommerce-taxonomy-edit__option-thumbnail--empty" />
- ) }
- <span className="woocommerce-taxonomy-edit__option-label">
- { suggestionItem.label }
- </span>
- </Stack>
- );
- },
- [ hasImages, itemsMap ]
- );
-
return (
- <div className="woocommerce-taxonomy-edit">
- <FormTokenField
- __next40pxDefaultSize
- __experimentalAutoSelectFirstMatch
- __experimentalExpandOnFocus
- __experimentalRenderItem={ renderSuggestionItem }
- __experimentalShowHowTo={ false }
- label={ field.label }
- disabled={ isCreating }
- displayTransform={ displayTransform }
- maxSuggestions={ suggestions.length }
- onChange={ handleValueChange }
- onInputChange={ setInputValue }
- placeholder={
- searchPlaceholder ?? __( 'Search', 'woocommerce' )
- }
- saveTransform={ saveTransform }
- suggestions={ suggestions }
- value={ value }
- />
- { getHelpContent(
- field.description,
+ <SearchableChipSelectControl
+ label={ field.label }
+ description={ field.description }
+ items={ items }
+ value={ value }
+ onValueChange={ handleValueChange }
+ inputValue={ inputValue }
+ onInputValueChange={ setInputValue }
+ creatableItem={ isLoading ? undefined : creatableItem }
+ searchPlaceholder={
+ searchPlaceholder ?? __( 'Search', 'woocommerce' )
+ }
+ disabled={ isCreating }
+ emptyContent={ getEmptyContent(
isLoading,
isServerSearch,
inputValue
) }
- </div>
+ // Disable client-side filtering when using server-side search.
+ { ...( isServerSearch ? { filter: null } : {} ) }
+ chipsContent={
+ hasImages
+ ? ( selectedItems: Item[] ) =>
+ selectedItems.map( ( item ) => (
+ <Combobox.ChipWithRemove
+ key={ item.value }
+ prefix={
+ item.image?.src ? (
+ <img
+ src={ item.image.src }
+ alt={ item.image.alt ?? '' }
+ className="woocommerce-next-taxonomy-edit__chip-thumbnail"
+ />
+ ) : (
+ <span className="woocommerce-next-taxonomy-edit__chip-thumbnail woocommerce-next-taxonomy-edit__chip-thumbnail--empty" />
+ )
+ }
+ >
+ { item.label }
+ </Combobox.ChipWithRemove>
+ ) )
+ : undefined
+ }
+ >
+ { hasImages
+ ? ( item: Item ) => (
+ <Combobox.Item
+ key={ item.value }
+ value={ item }
+ disabled={ item.disabled }
+ >
+ <Stack
+ direction="row"
+ align="center"
+ style={ { gap: '12px' } }
+ className="woocommerce-next-taxonomy-edit__option"
+ >
+ { item.image?.src ? (
+ <img
+ src={ item.image.src }
+ alt={ item.image.alt ?? '' }
+ className="woocommerce-next-taxonomy-edit__option-thumbnail"
+ />
+ ) : (
+ <span className="woocommerce-next-taxonomy-edit__option-thumbnail woocommerce-next-taxonomy-edit__option-thumbnail--empty" />
+ ) }
+ <span className="woocommerce-next-taxonomy-edit__option-label">
+ { item.label }
+ </span>
+ </Stack>
+ </Combobox.Item>
+ )
+ : undefined }
+ </SearchableChipSelectControl>
);
}
diff --git a/packages/js/experimental-products-app/src/fields/components/taxonomy-edit/style.scss b/packages/js/experimental-products-app/src/fields/components/taxonomy-edit/style.scss
index cab17664b4e..63c70411b7e 100644
--- a/packages/js/experimental-products-app/src/fields/components/taxonomy-edit/style.scss
+++ b/packages/js/experimental-products-app/src/fields/components/taxonomy-edit/style.scss
@@ -1,4 +1,4 @@
-.woocommerce-taxonomy-edit {
+.woocommerce-next-taxonomy-edit {
&__chip-thumbnail {
width: 19px;
height: 19px;
diff --git a/packages/js/experimental-products-app/src/style.scss b/packages/js/experimental-products-app/src/style.scss
index 2eaa0356570..694e3cea8ec 100644
--- a/packages/js/experimental-products-app/src/style.scss
+++ b/packages/js/experimental-products-app/src/style.scss
@@ -24,6 +24,8 @@
#woocommerce-variations-classic-root,
.woocommerce-product-edit__drawer-portal {
@import "./fields/components/list-item/style.scss";
+ @import "./fields/components/searchable-chip-select/style.scss";
+ @import "./fields/components/taxonomy-edit/style.scss";
@import "./fields/downloadable/style.scss";
@import "./fields/images/style.scss";
@import "./fields/stock/style.scss";