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";