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>