From 673fe8b55cbfe7d8961ffdf49a105ce86288f2ed Mon Sep 17 00:00:00 2001 From: Jos Velasco Date: Sat, 7 Feb 2026 10:39:45 -0600 Subject: [PATCH 1/4] Components: Preserve ColorPicker hue when saturation picker reaches black/white Switch the internal color picker from RgbStringColorPicker to HslColorPicker so that hue is passed as a discrete channel rather than being discarded during RGB string serialization. A ref preserves the last meaningful hue for achromatic edge cases (black, white, gray) where hex round-tripping reports hue as 0. This is the standard approach used by professional color pickers (Figma, Photoshop, react-colorful) where HSLA serves as the internal model and each UI control only mutates its own channel. --- packages/components/CHANGELOG.md | 1 + .../components/src/color-picker/picker.tsx | 110 +++++++++++++++--- .../src/color-picker/test/index.tsx | 98 ++++++++++++++++ 3 files changed, 190 insertions(+), 19 deletions(-) diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 9a8aa39e9f06ce..a0b67c002604e6 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -7,6 +7,7 @@ - `Text`: Remove `text-wrap: balance` fallback. Only `text-wrap: pretty` is now used ([#75089](https://github.com/WordPress/gutenberg/pull/75089)). - `RangeControl`: support forced-colors mode ([#75165](https://github.com/WordPress/gutenberg/pull/75165)). - `ToggleControl`: Prevent `__nextHasNoMarginBottom` from logging a console warning ([#75296](https://github.com/WordPress/gutenberg/pull/75296)). +- `ColorPicker`: Preserve hue when the saturation picker reaches black, white, or gray ([#75157](https://github.com/WordPress/gutenberg/pull/75157)). ### Enhancements diff --git a/packages/components/src/color-picker/picker.tsx b/packages/components/src/color-picker/picker.tsx index f2c068d8a46be2..e132c47fa024b2 100644 --- a/packages/components/src/color-picker/picker.tsx +++ b/packages/components/src/color-picker/picker.tsx @@ -1,39 +1,111 @@ /** * External dependencies */ -import { RgbStringColorPicker, RgbaStringColorPicker } from 'react-colorful'; +import { HsvColorPicker, HsvaColorPicker } from 'react-colorful'; import { colord } from 'colord'; /** * WordPress dependencies */ -import { useMemo } from '@wordpress/element'; +import { useMemo, useRef } from '@wordpress/element'; + /** * Internal dependencies */ import type { PickerProps } from './types'; +/** + * Checks whether an HSV color carries meaningful chromatic information. + * Achromatic colors (very low saturation or value near zero) report + * hue as 0 after hex round-tripping, so their hue is unreliable. + */ +function isChromatic( hsv: { s: number; v: number } ): boolean { + return hsv.s >= 1 && hsv.v >= 1; +} + export const Picker = ( { color, enableAlpha, onChange }: PickerProps ) => { - const Component = enableAlpha - ? RgbaStringColorPicker - : RgbStringColorPicker; - const rgbColor = useMemo( () => color.toRgbString(), [ color ] ); + const Component = enableAlpha ? HsvaColorPicker : HsvColorPicker; + + // Preserve the last meaningful hue to prevent it resetting to 0 (red) + // when the color reaches achromatic points (pure black, white, or gray). + // + // This is needed because the parent component stores color as hex, which + // discards hue for achromatic colors (e.g. #000000 → hsv(0, 0%, 0%)). + // By feeding the preserved hue into react-colorful's HSV picker — which + // uses HSVA as its native model — the hue slider stays stable with no + // intermediate conversion. + // + // This is the standard approach used by Figma, Photoshop, react-colorful + // itself, and other professional color pickers. + const preservedHueRef = useRef( color.toHsv().h ); + + // Cache the last HSV we produced and the corresponding hex to avoid + // precision jitter during dragging. Without this, the HSV→hex→HSV + // round-trip through the parent can produce slightly different values, + // causing the thumb to shift on re-render. + const lastHsvRef = useRef< { + h: number; + s: number; + v: number; + a: number; + } | null >( null ); + const lastHexRef = useRef( '' ); + + // Note: this useMemo intentionally reads and writes refs for hue + // preservation and jitter avoidance. This is an accepted trade-off + // since the ref updates are synchronous and idempotent — the same + // input color will always produce the same ref values. + const hsvColor = useMemo( () => { + const currentHex = color.toHex(); + + // If the hex matches what we last emitted, return the cached HSV + // to avoid floating-point precision drift from hex → HSV conversion. + if ( lastHsvRef.current && currentHex === lastHexRef.current ) { + return lastHsvRef.current; + } + + // External change — parse from hex and preserve hue if chromatic. + const hsv = color.toHsv(); + + if ( isChromatic( hsv ) ) { + preservedHueRef.current = hsv.h; + } + + const result = { + h: preservedHueRef.current, + s: hsv.s, + v: hsv.v, + a: hsv.a, + }; + + lastHsvRef.current = result; + lastHexRef.current = currentHex; + + return result; + }, [ color ] ); return ( { - onChange( colord( nextColor ) ); - } } - // Pointer capture fortifies drag gestures so that they continue to - // work while dragging outside the component over objects like - // iframes. If a newer version of react-colorful begins to employ - // pointer capture this will be redundant and should be removed. - onPointerDown={ ( { currentTarget, pointerId } ) => { - currentTarget.setPointerCapture( pointerId ); - } } - onPointerUp={ ( { currentTarget, pointerId } ) => { - currentTarget.releasePointerCapture( pointerId ); + color={ hsvColor } + onChange={ ( nextHsv ) => { + if ( isChromatic( nextHsv ) ) { + preservedHueRef.current = nextHsv.h; + } + + const nextColord = colord( nextHsv ); + + // Cache the HSV and hex to prevent jitter on re-render. + lastHsvRef.current = { + h: isChromatic( nextHsv ) + ? nextHsv.h + : preservedHueRef.current, + s: nextHsv.s, + v: nextHsv.v, + a: 'a' in nextHsv ? nextHsv.a : 1, + }; + lastHexRef.current = nextColord.toHex(); + + onChange( nextColord ); } } /> ); diff --git a/packages/components/src/color-picker/test/index.tsx b/packages/components/src/color-picker/test/index.tsx index c493a733dca1db..b4cffd4a1292b0 100644 --- a/packages/components/src/color-picker/test/index.tsx +++ b/packages/components/src/color-picker/test/index.tsx @@ -419,4 +419,102 @@ describe( 'ColorPicker', () => { expect( alphaInput ).toHaveValue( 75 ); } ); } ); + + describe( 'Hue preservation', () => { + it( 'should preserve the picker hue when the color becomes black', async () => { + const user = userEvent.setup(); + + render( + + ); + + const pickerHueSlider = screen.getByRole( 'slider', { + name: 'Hue', + } ); + + // Blue (#3366ff) has a hue of 225°. + expect( pickerHueSlider ).toHaveAttribute( 'aria-valuenow', '225' ); + + // Change color to black via hex input. + const hexInput = screen.getByRole( 'textbox' ); + await user.clear( hexInput ); + await user.type( hexInput, '000000' ); + + // The picker hue should still be 225° (blue), not 0° (red). + await waitFor( () => { + expect( pickerHueSlider ).toHaveAttribute( + 'aria-valuenow', + '225' + ); + } ); + } ); + + it( 'should preserve the picker hue when the color becomes white', async () => { + const user = userEvent.setup(); + + render( + + ); + + const pickerHueSlider = screen.getByRole( 'slider', { + name: 'Hue', + } ); + + // Green (#38cc38) has a hue of 120°. + expect( pickerHueSlider ).toHaveAttribute( 'aria-valuenow', '120' ); + + // Change color to white via hex input. + const hexInput = screen.getByRole( 'textbox' ); + await user.clear( hexInput ); + await user.type( hexInput, 'ffffff' ); + + // The picker hue should still be 120° (green), not 0° (red). + await waitFor( () => { + expect( pickerHueSlider ).toHaveAttribute( + 'aria-valuenow', + '120' + ); + } ); + } ); + + it( 'should preserve the picker hue when the color becomes gray', async () => { + const user = userEvent.setup(); + + render( + + ); + + const pickerHueSlider = screen.getByRole( 'slider', { + name: 'Hue', + } ); + + // Pure blue (#0000ff) has a hue of 240°. + expect( pickerHueSlider ).toHaveAttribute( 'aria-valuenow', '240' ); + + // Change color to gray via hex input. + const hexInput = screen.getByRole( 'textbox' ); + await user.clear( hexInput ); + await user.type( hexInput, '999' ); + + // The picker hue should still be 240° (blue), not 0° (red). + await waitFor( () => { + expect( pickerHueSlider ).toHaveAttribute( + 'aria-valuenow', + '240' + ); + } ); + } ); + } ); } ); From 02e6373b0c89e019f9c9e3fc7249031c1c13d9c9 Mon Sep 17 00:00:00 2001 From: Jos Velasco Date: Tue, 10 Feb 2026 11:00:59 -0600 Subject: [PATCH 2/4] Update packages/components/CHANGELOG.md Co-authored-by: Marco Ciampini --- packages/components/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 51107a3fbb31cf..a96bfc3667a0d8 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -8,7 +8,7 @@ - `Text`: Remove `text-wrap: balance` fallback. Only `text-wrap: pretty` is now used ([#75089](https://github.com/WordPress/gutenberg/pull/75089)). - `RangeControl`: support forced-colors mode ([#75165](https://github.com/WordPress/gutenberg/pull/75165)). - `ToggleControl`: Prevent `__nextHasNoMarginBottom` from logging a console warning ([#75296](https://github.com/WordPress/gutenberg/pull/75296)). -- `ColorPicker`: Preserve hue when the saturation picker reaches black, white, or gray ([#75157](https://github.com/WordPress/gutenberg/pull/75157)). +- `ColorPicker`: Preserve hue when the selected color becomes a-chromatic ([#75157](https://github.com/WordPress/gutenberg/pull/75157)). - `createSlotFill`: fix ref forwarding ([#75274](https://github.com/WordPress/gutenberg/pull/75274)). - `Button`: Fix native outline showing when focused and active ([#75346](https://github.com/WordPress/gutenberg/pull/75346)). From d8e89b6c44d5ba7c541209ed9c450ad3b898cc09 Mon Sep 17 00:00:00 2001 From: Jos Velasco Date: Tue, 10 Feb 2026 11:09:28 -0600 Subject: [PATCH 3/4] Address review feedback: toHaveValue, pointer capture, changelog wording, cache isChromatic --- .../components/src/color-picker/picker.tsx | 18 ++++++++++++---- .../src/color-picker/test/index.tsx | 21 ++++++------------- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/packages/components/src/color-picker/picker.tsx b/packages/components/src/color-picker/picker.tsx index e132c47fa024b2..c9eec357b74da7 100644 --- a/packages/components/src/color-picker/picker.tsx +++ b/packages/components/src/color-picker/picker.tsx @@ -88,7 +88,9 @@ export const Picker = ( { color, enableAlpha, onChange }: PickerProps ) => { { - if ( isChromatic( nextHsv ) ) { + const chromatic = isChromatic( nextHsv ); + + if ( chromatic ) { preservedHueRef.current = nextHsv.h; } @@ -96,9 +98,7 @@ export const Picker = ( { color, enableAlpha, onChange }: PickerProps ) => { // Cache the HSV and hex to prevent jitter on re-render. lastHsvRef.current = { - h: isChromatic( nextHsv ) - ? nextHsv.h - : preservedHueRef.current, + h: chromatic ? nextHsv.h : preservedHueRef.current, s: nextHsv.s, v: nextHsv.v, a: 'a' in nextHsv ? nextHsv.a : 1, @@ -107,6 +107,16 @@ export const Picker = ( { color, enableAlpha, onChange }: PickerProps ) => { onChange( nextColord ); } } + // Pointer capture fortifies drag gestures so that they continue to + // work while dragging outside the component over objects like + // iframes. If a newer version of react-colorful begins to employ + // pointer capture this will be redundant and should be removed. + onPointerDown={ ( { currentTarget, pointerId } ) => { + currentTarget.setPointerCapture( pointerId ); + } } + onPointerUp={ ( { currentTarget, pointerId } ) => { + currentTarget.releasePointerCapture( pointerId ); + } } /> ); }; diff --git a/packages/components/src/color-picker/test/index.tsx b/packages/components/src/color-picker/test/index.tsx index b4cffd4a1292b0..fbcd75b8b769b8 100644 --- a/packages/components/src/color-picker/test/index.tsx +++ b/packages/components/src/color-picker/test/index.tsx @@ -437,7 +437,7 @@ describe( 'ColorPicker', () => { } ); // Blue (#3366ff) has a hue of 225°. - expect( pickerHueSlider ).toHaveAttribute( 'aria-valuenow', '225' ); + expect( pickerHueSlider ).toHaveValue( 225 ); // Change color to black via hex input. const hexInput = screen.getByRole( 'textbox' ); @@ -446,10 +446,7 @@ describe( 'ColorPicker', () => { // The picker hue should still be 225° (blue), not 0° (red). await waitFor( () => { - expect( pickerHueSlider ).toHaveAttribute( - 'aria-valuenow', - '225' - ); + expect( pickerHueSlider ).toHaveValue( 225 ); } ); } ); @@ -469,7 +466,7 @@ describe( 'ColorPicker', () => { } ); // Green (#38cc38) has a hue of 120°. - expect( pickerHueSlider ).toHaveAttribute( 'aria-valuenow', '120' ); + expect( pickerHueSlider ).toHaveValue( 120 ); // Change color to white via hex input. const hexInput = screen.getByRole( 'textbox' ); @@ -478,10 +475,7 @@ describe( 'ColorPicker', () => { // The picker hue should still be 120° (green), not 0° (red). await waitFor( () => { - expect( pickerHueSlider ).toHaveAttribute( - 'aria-valuenow', - '120' - ); + expect( pickerHueSlider ).toHaveValue( 120 ); } ); } ); @@ -501,7 +495,7 @@ describe( 'ColorPicker', () => { } ); // Pure blue (#0000ff) has a hue of 240°. - expect( pickerHueSlider ).toHaveAttribute( 'aria-valuenow', '240' ); + expect( pickerHueSlider ).toHaveValue( 240 ); // Change color to gray via hex input. const hexInput = screen.getByRole( 'textbox' ); @@ -510,10 +504,7 @@ describe( 'ColorPicker', () => { // The picker hue should still be 240° (blue), not 0° (red). await waitFor( () => { - expect( pickerHueSlider ).toHaveAttribute( - 'aria-valuenow', - '240' - ); + expect( pickerHueSlider ).toHaveValue( 240 ); } ); } ); } ); From 64e5501e2e46a42144defafb16220950eef64992 Mon Sep 17 00:00:00 2001 From: Jos Velasco Date: Wed, 11 Feb 2026 10:21:57 -0600 Subject: [PATCH 4/4] Consolidate hue preservation tests into a single test --- .../src/color-picker/test/index.tsx | 78 +++---------------- 1 file changed, 11 insertions(+), 67 deletions(-) diff --git a/packages/components/src/color-picker/test/index.tsx b/packages/components/src/color-picker/test/index.tsx index fbcd75b8b769b8..3e5e8bf58792d1 100644 --- a/packages/components/src/color-picker/test/index.tsx +++ b/packages/components/src/color-picker/test/index.tsx @@ -421,7 +421,7 @@ describe( 'ColorPicker', () => { } ); describe( 'Hue preservation', () => { - it( 'should preserve the picker hue when the color becomes black', async () => { + it( 'should preserve the picker hue when the color becomes achromatic', async () => { const user = userEvent.setup(); render( @@ -439,73 +439,17 @@ describe( 'ColorPicker', () => { // Blue (#3366ff) has a hue of 225°. expect( pickerHueSlider ).toHaveValue( 225 ); - // Change color to black via hex input. const hexInput = screen.getByRole( 'textbox' ); - await user.clear( hexInput ); - await user.type( hexInput, '000000' ); - - // The picker hue should still be 225° (blue), not 0° (red). - await waitFor( () => { - expect( pickerHueSlider ).toHaveValue( 225 ); - } ); - } ); - - it( 'should preserve the picker hue when the color becomes white', async () => { - const user = userEvent.setup(); - - render( - - ); - - const pickerHueSlider = screen.getByRole( 'slider', { - name: 'Hue', - } ); - - // Green (#38cc38) has a hue of 120°. - expect( pickerHueSlider ).toHaveValue( 120 ); - - // Change color to white via hex input. - const hexInput = screen.getByRole( 'textbox' ); - await user.clear( hexInput ); - await user.type( hexInput, 'ffffff' ); - - // The picker hue should still be 120° (green), not 0° (red). - await waitFor( () => { - expect( pickerHueSlider ).toHaveValue( 120 ); - } ); - } ); - - it( 'should preserve the picker hue when the color becomes gray', async () => { - const user = userEvent.setup(); - - render( - - ); - - const pickerHueSlider = screen.getByRole( 'slider', { - name: 'Hue', - } ); - - // Pure blue (#0000ff) has a hue of 240°. - expect( pickerHueSlider ).toHaveValue( 240 ); - - // Change color to gray via hex input. - const hexInput = screen.getByRole( 'textbox' ); - await user.clear( hexInput ); - await user.type( hexInput, '999' ); - - // The picker hue should still be 240° (blue), not 0° (red). - await waitFor( () => { - expect( pickerHueSlider ).toHaveValue( 240 ); - } ); + for await ( const achromaticColor of [ '000', 'fff', '999' ] ) { + // Change color via hex input. + await user.clear( hexInput ); + await user.type( hexInput, achromaticColor ); + + // The picker hue should still be 225° (blue), not 0° (red). + await waitFor( () => { + expect( pickerHueSlider ).toHaveValue( 225 ); + } ); + } } ); } ); } );