Commit 40f7925c475 for woocommerce

commit 40f7925c4754e0f293dc4a9f4d9d0e911b1210f0
Author: Albert Juhé Lluveras <contact@albertjuhe.com>
Date:   Wed May 6 13:27:45 2026 +0200

    Add color picker for wc-visual attributes (#64541)

    * Add color picker for wc-visual attributes

    * Clean up

    * Make it possible to reset colors

    * Clean up

    * CodeRabbit suggestions

    * Add aria-expanded to popover button

    * CodeRabbit suggestions

    * Reset color picker after creating a term

    * Fix Color value not appearing in the new term row

    * Remove unnecessary 'as never' type

    * Only render 'Clear' button when a color is selected

    * Cleanup

    * Fix input not resetting because it wasn't visible

    * Get rid of vertical-align: center

diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/visual-attribute-color-picker/index.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/visual-attribute-color-picker/index.tsx
new file mode 100644
index 00000000000..0a82c2764af
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/visual-attribute-color-picker/index.tsx
@@ -0,0 +1,205 @@
+/**
+ * External dependencies
+ */
+import { ColorPicker, Popover } from '@wordpress/components';
+import { createRoot, useEffect, useRef, useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+
+const INPUT_SELECTOR = 'input.wc-admin-visual-attribute-color-input';
+const WRAPPER_CLASS = 'wc-admin-visual-attribute-color-picker-root';
+const FALLBACK_COLOR = '#000000';
+const EMPTY_COLOR_VALUE = '';
+
+const normalizeColor = ( value: string ) => {
+	if ( typeof value !== 'string' || ! value ) {
+		return '';
+	}
+
+	return value.trim().toLowerCase();
+};
+
+const getInitialColor = ( input: HTMLInputElement ) => {
+	const attributeValue = normalizeColor(
+		input.value || input.getAttribute( 'value' ) || ''
+	);
+
+	return attributeValue;
+};
+
+const ColorField = ( { input }: { input: HTMLInputElement } ) => {
+	const [ color, setColor ] = useState( () => getInitialColor( input ) );
+	const [ isPopoverVisible, setIsPopoverVisible ] = useState( false );
+	const triggerRef = useRef< HTMLButtonElement | null >( null );
+
+	// Listen to changes in the input field. Because WP core uses jQuery, we
+	// can't listen to native `change` and `input` events. Instead, we override
+	// the `value` property to sync input changes to the color picker.
+	// @see https://github.com/WordPress/wordpress-develop/blob/bd4e3c97903743ab455682f32dbf38d1b38b715a/src/js/_enqueues/admin/tags.js#L194
+	useEffect( () => {
+		const syncColorWithInput = ( nextValue: string ) => {
+			const nextColor = normalizeColor( nextValue );
+
+			setColor( nextColor );
+		};
+		const inputPrototype = HTMLInputElement.prototype;
+		const valueDescriptor = Object.getOwnPropertyDescriptor(
+			inputPrototype,
+			'value'
+		);
+		let hasValueOverride = false;
+
+		if ( valueDescriptor?.get && valueDescriptor.set ) {
+			Object.defineProperty( input, 'value', {
+				...valueDescriptor,
+				configurable: true,
+				set( nextValue: string ) {
+					valueDescriptor.set?.call( this, nextValue );
+					syncColorWithInput( nextValue );
+				},
+			} );
+			hasValueOverride = true;
+		}
+
+		return () => {
+			if ( hasValueOverride ) {
+				delete ( input as { value?: string } ).value;
+			}
+		};
+	}, [ input ] );
+
+	useEffect( () => {
+		if ( normalizeColor( input.value ) === color ) {
+			return;
+		}
+		input.value = color;
+		input.dispatchEvent( new Event( 'input', { bubbles: true } ) );
+		input.dispatchEvent( new Event( 'change', { bubbles: true } ) );
+	}, [ color, input ] );
+
+	const handleColorSelection = ( value: string ) => {
+		const nextColor = normalizeColor( value );
+		setColor( nextColor );
+	};
+
+	const clearColor = () => {
+		setColor( EMPTY_COLOR_VALUE );
+		setIsPopoverVisible( false );
+	};
+
+	const displayedColorValue = color
+		? color.toUpperCase()
+		: __( 'Select a color', 'woocommerce' );
+
+	const popoverColor = color || FALLBACK_COLOR;
+
+	return (
+		<>
+			<button
+				ref={ triggerRef }
+				type="button"
+				className="wc-admin-visual-attribute-color-picker-trigger"
+				onClick={ () => setIsPopoverVisible( true ) }
+				aria-haspopup="dialog"
+				aria-expanded={ isPopoverVisible }
+			>
+				<span
+					className={ `wc-admin-color-swatch${
+						color ? '' : ' is-empty'
+					}` }
+					style={ color ? { backgroundColor: color } : undefined }
+					aria-hidden="true"
+				/>
+				<span>{ displayedColorValue }</span>
+			</button>
+			{ color && (
+				<button
+					type="button"
+					className="button-link wc-admin-visual-attribute-color-picker-clear"
+					onClick={ clearColor }
+				>
+					{ __( 'Clear', 'woocommerce' ) }
+				</button>
+			) }
+			{ isPopoverVisible && triggerRef.current && (
+				<Popover
+					anchor={ triggerRef.current }
+					onClose={ () => setIsPopoverVisible( false ) }
+					placement="bottom-start"
+				>
+					<ColorPicker
+						color={ popoverColor }
+						onChange={ handleColorSelection }
+					/>
+				</Popover>
+			) }
+		</>
+	);
+};
+
+const mountColorPicker = ( input: HTMLInputElement ) => {
+	if ( input.dataset.wcColorPickerMounted === '1' ) {
+		return;
+	}
+
+	input.dataset.wcColorPickerMounted = '1';
+	input.style.height = '0';
+	input.style.width = '0';
+	input.style.position = 'absolute';
+	input.style.top = '0';
+	input.style.left = '0';
+	input.style.opacity = '0';
+	input.style.visibility = 'hidden';
+	input.style.pointerEvents = 'none';
+	input.style.userSelect = 'none';
+
+	const wrapper = document.createElement( 'div' );
+	wrapper.className = WRAPPER_CLASS;
+	input.insertAdjacentElement( 'beforebegin', wrapper );
+
+	const root = createRoot( wrapper );
+	root.render( <ColorField input={ input } /> );
+
+	// Make sure labels associated to the input also trigger the color picker.
+	const associatedLabels = input.labels ? Array.from( input.labels ) : [];
+	associatedLabels.forEach( ( labelElement ) => {
+		labelElement.addEventListener( 'click', ( event ) => {
+			event.preventDefault();
+
+			const trigger = wrapper.querySelector< HTMLButtonElement >(
+				'.wc-admin-visual-attribute-color-picker-trigger'
+			);
+
+			trigger?.click();
+		} );
+	} );
+};
+
+const mountAllColorPickers = ( context: ParentNode = document ) => {
+	const colorInputs = context.querySelectorAll( INPUT_SELECTOR );
+
+	colorInputs.forEach( ( inputElement ) => {
+		if ( inputElement instanceof HTMLInputElement ) {
+			mountColorPicker( inputElement );
+		}
+	} );
+};
+
+const startObserver = () => {
+	const observer = new MutationObserver( ( mutationList ) => {
+		mutationList.forEach( ( mutation ) => {
+			mutation.addedNodes.forEach( ( node ) => {
+				if ( node instanceof HTMLElement ) {
+					mountAllColorPickers( node );
+				}
+			} );
+		} );
+	} );
+
+	observer.observe( document.body, {
+		childList: true,
+		subtree: true,
+	} );
+};
+
+mountAllColorPickers();
+startObserver();
diff --git a/plugins/woocommerce/client/legacy/css/admin.scss b/plugins/woocommerce/client/legacy/css/admin.scss
index e1e3aee2e6a..66347ae6454 100644
--- a/plugins/woocommerce/client/legacy/css/admin.scss
+++ b/plugins/woocommerce/client/legacy/css/admin.scss
@@ -1006,9 +1006,7 @@ $font-sf-pro-display: "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe U
 		width: 100%;
 	}

-	input[type="color"] {
-		display: block;
-		margin: 6px 0;
+	.wc-admin-visual-attribute-color-picker-trigger {
 		width: 100%;
 	}
 }
@@ -9615,12 +9613,49 @@ body.woocommerce-settings-payments-section_legacy {
 }

 .wc-admin-color-swatch {
+	background-color: #fff;
 	display: inline-block;
 	vertical-align: middle;
-	vertical-align: center;
 	border: 1px solid #7e8993;
 	border-radius: 2px;
-	margin-right: 6px;
-	width: 12px;
-	height: 12px;
+	margin-right: 0.5em;
+	height: 1em;
+	width: 1em;
+	position: relative;
+	top: -1px;
+
+	&.is-empty::before {
+		content: '';
+		position: absolute;
+		top: 0;
+		right: 0;
+		bottom: 0;
+		left: 0;
+		background: linear-gradient(to bottom right, transparent 47%, #7e8993 47%, #7e8993 53%, transparent 53%);
+	}
+}
+
+.wc-admin-visual-attribute-color-picker-trigger {
+	width: 95%;
+	display: flex;
+	align-items: center;
+	gap: 8px;
+	padding: 0 12px;
+	min-height: 40px;
+	background: #fff;
+	border: 1px solid #949494;
+	border-radius: 2px;
+	text-align: left;
+	font-size: 13px;
+	line-height: 1.4;
+	cursor: pointer;
+
+	+ .wc-admin-visual-attribute-color-picker-clear {
+		display: block;
+		margin: 2px 0 5px;
+	}
+
+	.wc-admin-color-swatch {
+		margin-right: 0;
+	}
 }
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-taxonomies.php b/plugins/woocommerce/includes/admin/class-wc-admin-taxonomies.php
index 71da485fca6..33655b92c4a 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-taxonomies.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-taxonomies.php
@@ -11,6 +11,7 @@ if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }

+use Automattic\WooCommerce\Internal\Admin\WCAdminAssets;
 use Automattic\WooCommerce\Internal\AssignDefaultCategory;

 /**
@@ -84,8 +85,20 @@ class WC_Admin_Taxonomies {
 				add_action( $taxonomy . '_pre_add_form', array( $this, 'product_attribute_description' ) );
 				add_action( $taxonomy . '_add_form_fields', array( $this, 'add_product_attribute_term_fields' ) );
 				add_action( $taxonomy . '_edit_form_fields', array( $this, 'edit_product_attribute_term_fields' ), 10, 1 );
-				add_filter( "manage_edit-{$taxonomy}_columns", array( $this, 'add_term_color_columns' ) );
-				add_filter( "manage_{$taxonomy}_custom_column", array( $this, 'render_term_color_column' ), 10, 3 );
+				add_filter(
+					"manage_edit-{$taxonomy}_columns",
+					function ( $columns ) use ( $taxonomy ) {
+						return $this->add_term_color_columns( $columns, $taxonomy );
+					}
+				);
+				add_filter(
+					"manage_{$taxonomy}_custom_column",
+					function ( $content, $column, $term_id ) use ( $taxonomy ) {
+						return $this->render_term_color_column( $content, $column, $term_id, $taxonomy );
+					},
+					10,
+					3
+				);
 			}
 		}

@@ -93,6 +106,7 @@ class WC_Admin_Taxonomies {
 		add_filter( 'wp_terms_checklist_args', array( $this, 'disable_checked_ontop' ) );

 		// Admin footer scripts for taxonomy screens.
+		add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_visual_attribute_color_picker_script' ) );
 		add_action( 'admin_footer', array( $this, 'scripts_at_product_cat_screen_footer' ) );
 		add_action( 'admin_footer', array( $this, 'scripts_at_visual_attribute_screen_footer' ) );
 	}
@@ -349,7 +363,7 @@ class WC_Admin_Taxonomies {
 		?>
 		<div class="form-field term-color-wrap">
 			<label for="term_color"><?php esc_html_e( 'Color value', 'woocommerce' ); ?></label>
-			<input name="term_color" id="term_color" type="color" value="" />
+			<input name="term_color" id="term_color" class="wc-admin-visual-attribute-color-input" type="text" value="" />
 		</div>
 		<?php
 	}
@@ -372,12 +386,40 @@ class WC_Admin_Taxonomies {
 		<tr class="form-field term-color-wrap">
 			<th scope="row" valign="top"><label for="term_color"><?php esc_html_e( 'Color value', 'woocommerce' ); ?></label></th>
 			<td>
-				<input name="term_color" id="term_color" type="color" value="<?php echo esc_attr( $color_value ); ?>" />
+				<input name="term_color" id="term_color" class="wc-admin-visual-attribute-color-input" type="text" value="<?php echo esc_attr( $color_value ); ?>" />
 			</td>
 		</tr>
 		<?php
 	}

+	/**
+	 * Enqueue Gutenberg color picker script for visual attribute forms.
+	 *
+	 * @return void
+	 */
+	public function enqueue_visual_attribute_color_picker_script() {
+		$screen = get_current_screen();
+
+		if ( ! $screen ) {
+			return;
+		}
+
+		$is_product_editor_screen = 'product' === $screen->id;
+
+		if ( $is_product_editor_screen && array_key_exists( 'wc-visual', wc_get_attribute_types() ) ) {
+			WCAdminAssets::register_script( 'wp-admin-scripts', 'visual-attribute-color-picker', true, array( 'wp-components' ) );
+			return;
+		}
+
+		$is_attribute_term_screen = 0 === strpos( $screen->id, 'edit-pa_' );
+		$taxonomy                 = isset( $_GET['taxonomy'] ) ? sanitize_text_field( wp_unslash( $_GET['taxonomy'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+
+		if ( $is_attribute_term_screen && $this->is_visual_product_attribute_taxonomy( $taxonomy ) ) {
+			WCAdminAssets::register_script( 'wp-admin-scripts', 'visual-attribute-color-picker', true, array( 'wp-components' ) );
+			return;
+		}
+	}
+
 	/**
 	 * Save category fields
 	 *
@@ -397,6 +439,8 @@ class WC_Admin_Taxonomies {

 			if ( $color_value ) {
 				update_term_meta( $term_id, 'color', $color_value );
+			} elseif ( '' === $color_value ) {
+				delete_term_meta( $term_id, 'color' );
 			}
 		}
 	}
@@ -447,13 +491,13 @@ class WC_Admin_Taxonomies {
 	/**
 	 * Add custom columns for product attribute terms.
 	 *
-	 * @param array $columns Existing columns.
+	 * @param array  $columns  Existing columns.
+	 * @param string $taxonomy Taxonomy slug (bound when the filter is registered).
 	 * @return array
 	 *
 	 * @internal
 	 */
-	public function add_term_color_columns( $columns ) {
-		$taxonomy = isset( $_GET['taxonomy'] ) ? sanitize_text_field( wp_unslash( $_GET['taxonomy'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+	public function add_term_color_columns( $columns, $taxonomy ) {
 		if ( ! $this->is_visual_product_attribute_taxonomy( $taxonomy ) ) {
 			return $columns;
 		}
@@ -476,21 +520,21 @@ class WC_Admin_Taxonomies {
 	/**
 	 * Render color column for product attribute terms.
 	 *
-	 * @param string $columns Existing columns HTML.
-	 * @param string $column  Current column key.
-	 * @param int    $term_id Term ID.
+	 * @param string $content  Column output so far (often empty string).
+	 * @param string $column   Current column key.
+	 * @param int    $term_id  Term ID.
+	 * @param string $taxonomy Taxonomy slug (bound when the filter is registered).
 	 * @return string
 	 *
 	 * @internal
 	 */
-	public function render_term_color_column( $columns, $column, $term_id ) {
+	public function render_term_color_column( $content, $column, $term_id, $taxonomy ) {
 		if ( 'color' !== $column ) {
-			return $columns;
+			return $content;
 		}

-		$taxonomy = isset( $_GET['taxonomy'] ) ? sanitize_text_field( wp_unslash( $_GET['taxonomy'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
 		if ( ! $this->is_visual_product_attribute_taxonomy( $taxonomy ) ) {
-			return $columns;
+			return $content;
 		}

 		$color_value = get_term_meta( $term_id, 'color', true );
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-attributes.php b/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-attributes.php
index 1c1300ede84..f26e4e94f4f 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-attributes.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-attributes.php
@@ -79,7 +79,7 @@ $product_attributes = $product_object->get_attributes( 'edit' );
 						<input id="wc-modal-add-attribute-term-input" type="text" name="term" value="" />
 						<# if ( data.isVisualAttribute ) { #>
 							<label for="wc-modal-add-attribute-term-color"><?php esc_html_e( 'Color value', 'woocommerce' ); ?></label>
-							<input id="wc-modal-add-attribute-term-color" type="color" name="term_color" value="" />
+							<input id="wc-modal-add-attribute-term-color" class="wc-admin-visual-attribute-color-input" type="text" name="term_color" value="" />
 						<# } #>
 					</form>
 				</article>