diff --git a/packages/block-editor/src/components/color-palette/test/__snapshots__/control.js.snap b/packages/block-editor/src/components/color-palette/test/__snapshots__/control.js.snap index 3d082a14a92bf..8f02a338d1de2 100644 --- a/packages/block-editor/src/components/color-palette/test/__snapshots__/control.js.snap +++ b/packages/block-editor/src/components/color-palette/test/__snapshots__/control.js.snap @@ -190,9 +190,12 @@ exports[`ColorPaletteControl matches the snapshot 1`] = ` red #f00 diff --git a/packages/block-editor/src/components/color-palette/test/control.js b/packages/block-editor/src/components/color-palette/test/control.js index e1a5529544f79..62dd023266512 100644 --- a/packages/block-editor/src/components/color-palette/test/control.js +++ b/packages/block-editor/src/components/color-palette/test/control.js @@ -1,7 +1,14 @@ /** * External dependencies */ -import { render, waitFor, queryByAttribute } from '@testing-library/react'; +import { + render, + waitFor, + queryByAttribute, + fireEvent, + screen, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; /** * Internal dependencies @@ -37,4 +44,116 @@ describe( 'ColorPaletteControl', () => { expect( container ).toMatchSnapshot(); } ); + + it( 'allows editing hex value on click', async () => { + await renderAndValidate( + + ); + + const hexValue = screen.getByRole( 'button', { + name: /click to edit hex value/i, + } ); + await userEvent.click( hexValue ); + + const input = screen.getByRole( 'textbox', { + name: /edit color hex value/i, + } ); + expect( input ).toBeInTheDocument(); + expect( input ).toHaveValue( '#f00' ); + } ); + + it( 'validates hex input on blur', async () => { + const onChange = jest.fn(); + await renderAndValidate( + + ); + + const hexValue = screen.getByRole( 'button', { + name: /click to edit hex value/i, + } ); + await userEvent.click( hexValue ); + + const input = screen.getByRole( 'textbox', { + name: /edit color hex value/i, + } ); + await userEvent.clear( input ); + await userEvent.type( input, '#ff0000' ); + fireEvent.blur( input ); + + expect( onChange ).toHaveBeenCalledWith( '#ff0000' ); + } ); + + it( 'reverts to previous value on invalid hex', async () => { + const onChange = jest.fn(); + await renderAndValidate( + + ); + + const hexValue = screen.getByRole( 'button', { + name: /click to edit hex value/i, + } ); + await userEvent.click( hexValue ); + + const input = screen.getByRole( 'textbox', { + name: /edit color hex value/i, + } ); + await userEvent.clear( input ); + await userEvent.type( input, 'invalid' ); + fireEvent.blur( input ); + + expect( onChange ).not.toHaveBeenCalled(); + expect( + screen.getByRole( 'button', { name: /click to edit hex value/i } ) + ).toHaveTextContent( '#f00' ); + } ); + + it( 'handles keyboard interaction', async () => { + const onChange = jest.fn(); + await renderAndValidate( + + ); + + const hexValue = screen.getByRole( 'button', { + name: /click to edit hex value/i, + } ); + + fireEvent.keyDown( hexValue, { key: 'Enter' } ); + const input = screen.getByRole( 'textbox', { + name: /edit color hex value/i, + } ); + expect( input ).toBeInTheDocument(); + + await userEvent.clear( input ); + await userEvent.type( input, '#00f' ); + fireEvent.keyDown( input, { key: 'Escape' } ); + + expect( onChange ).not.toHaveBeenCalled(); + expect( + screen.getByRole( 'button', { name: /click to edit hex value/i } ) + ).toHaveTextContent( '#f00' ); + } ); } ); diff --git a/packages/components/src/color-palette/README.md b/packages/components/src/color-palette/README.md index 0fbfbd4d71001..2ffe183ca8548 100644 --- a/packages/components/src/color-palette/README.md +++ b/packages/components/src/color-palette/README.md @@ -32,6 +32,15 @@ for the `ColorPalette`'s color swatches, by rendering your `ColorPalette` with a `Popover.Slot` further up the element tree and within a `SlotFillProvider` overall. +## Features + +- Select from predefined color swatches +- Custom color picker with optional alpha channel +- Direct hex value editing for hex colors (click the hex value to edit) +- Clearing color selection +- Multiple palette support +- Keyboard navigation + ## Props The component accepts the following props. diff --git a/packages/components/src/color-palette/index.tsx b/packages/components/src/color-palette/index.tsx index de4e4f4206fe3..9c82ffc970701 100644 --- a/packages/components/src/color-palette/index.tsx +++ b/packages/components/src/color-palette/index.tsx @@ -1,7 +1,12 @@ /** * External dependencies */ -import type { ForwardedRef } from 'react'; +import type { + ForwardedRef, + MouseEvent, + ChangeEvent, + KeyboardEvent, +} from 'react'; import { colord, extend } from 'colord'; import namesPlugin from 'colord/plugins/names'; import a11yPlugin from 'colord/plugins/a11y'; @@ -12,7 +17,14 @@ import clsx from 'clsx'; */ import { useInstanceId } from '@wordpress/compose'; import { __, sprintf } from '@wordpress/i18n'; -import { useCallback, useMemo, useState, forwardRef } from '@wordpress/element'; +import { + useCallback, + useMemo, + useState, + forwardRef, + useEffect, + useRef, +} from '@wordpress/element'; /** * Internal dependencies @@ -31,6 +43,7 @@ import type { MultiplePalettesProps, PaletteObject, SinglePaletteProps, + CustomColorValueInputProps, } from './types'; import type { WordPressComponentProps } from '../context'; import type { DropdownProps } from '../dropdown/types'; @@ -42,6 +55,97 @@ import { extend( [ namesPlugin, a11yPlugin ] ); +function CustomColorValueInput( { + value, + onChange, + isHex, +}: CustomColorValueInputProps ) { + const [ isEditing, setIsEditing ] = useState( false ); + const [ inputValue, setInputValue ] = useState( value ); + const inputRef = useRef< HTMLInputElement >( null ); + const truncateRef = useRef< HTMLDivElement >( null ); + + useEffect( () => { + setInputValue( value ); + }, [ value ] ); + + useEffect( () => { + if ( isEditing && inputRef.current ) { + inputRef.current.focus(); + } + }, [ isEditing ] ); + + const handleClick = ( e: MouseEvent< HTMLDivElement > ) => { + e.preventDefault(); + if ( isHex ) { + setIsEditing( true ); + } + }; + + const handleChange = ( e: ChangeEvent< HTMLInputElement > ) => { + setInputValue( e.target.value ); + }; + + const handleTruncateKeyDown = ( e: KeyboardEvent< HTMLDivElement > ) => { + if ( isHex && ( e.key === 'Enter' || e.key === ' ' ) ) { + e.preventDefault(); + setIsEditing( true ); + } + }; + + const handleBlur = () => { + setIsEditing( false ); + if ( isHex && /^#[0-9A-Fa-f]{6}$/.test( inputValue || '' ) ) { + onChange( inputValue ); + } else { + setInputValue( value ); + } + }; + + const handleKeyDown = ( e: KeyboardEvent< HTMLInputElement > ) => { + if ( e.key === 'Enter' ) { + ( e.target as HTMLInputElement ).blur(); + } else if ( e.key === 'Escape' ) { + setInputValue( value ); + setIsEditing( false ); + truncateRef.current?.focus(); + } + }; + + if ( isEditing && isHex ) { + return ( + + ); + } + + return ( + + { value } + + ); +} + function SinglePalette( { className, clearColor, @@ -220,7 +324,7 @@ function UnforwardedColorPalette( /> ); - const isHex = value?.startsWith( '#' ); + const isHex = value?.startsWith( '#' ) ?? false; // Leave hex values as-is. Remove the `var()` wrapper from CSS vars. const displayValue = value?.replace( /^var\((.+)\)$/, '$1' ); @@ -311,22 +415,11 @@ function UnforwardedColorPalette( ? buttonLabelName : __( 'No color selected' ) } - { /* - This `Truncate` is always rendered, even if - there is no `displayValue`, to ensure the layout - does not shift - */ } - - { displayValue } - + ) } diff --git a/packages/components/src/color-palette/types.ts b/packages/components/src/color-palette/types.ts index b2a7a884fec57..c6f4446542e13 100644 --- a/packages/components/src/color-palette/types.ts +++ b/packages/components/src/color-palette/types.ts @@ -122,3 +122,9 @@ export type ColorPaletteProps = Pick< PaletteProps, 'onChange' > & { 'aria-label'?: never; } ); + +export type CustomColorValueInputProps = { + value?: string; + onChange: ( newColor?: string ) => void; + isHex: boolean; +};