Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +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)).
Comment thread
JosVelasco marked this conversation as resolved.
Resolved
- `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)).

Expand Down
110 changes: 91 additions & 19 deletions packages/components/src/color-picker/picker.tsx
Comment thread
JosVelasco marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
@@ -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 (
<Component
color={ rgbColor }
onChange={ ( nextColor ) => {
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 );
Comment thread
JosVelasco marked this conversation as resolved.
Resolved
color={ hsvColor }
onChange={ ( nextHsv ) => {
if ( isChromatic( nextHsv ) ) {
Comment thread
JosVelasco marked this conversation as resolved.
Resolved
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 );
} }
/>
);
Expand Down
98 changes: 98 additions & 0 deletions packages/components/src/color-picker/test/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ControlledColorPicker
onChange={ jest.fn() }
enableAlpha={ false }
initialColor="#3366ff"
/>
);

const pickerHueSlider = screen.getByRole( 'slider', {
name: 'Hue',
} );

// Blue (#3366ff) has a hue of 225°.
expect( pickerHueSlider ).toHaveAttribute( 'aria-valuenow', '225' );
Comment thread
JosVelasco marked this conversation as resolved.
Resolved

// 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(
<ControlledColorPicker
onChange={ jest.fn() }
enableAlpha={ false }
initialColor="#38cc38"
/>
);

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(
<ControlledColorPicker
onChange={ jest.fn() }
enableAlpha={ false }
initialColor="#0000ff"
/>
);

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'
);
} );
} );
} );
} );
Loading