From b4137c63ed6a10609db18d7a5689afd3ac8231ac Mon Sep 17 00:00:00 2001 From: dhruvikpatel18 Date: Mon, 17 Feb 2025 17:34:45 +0530 Subject: [PATCH 1/5] ColorPalette: Add editable hex label functionality --- .../components/src/color-palette/index.tsx | 111 +++++++++++++++--- .../components/src/color-palette/types.ts | 6 + 2 files changed, 98 insertions(+), 19 deletions(-) diff --git a/packages/components/src/color-palette/index.tsx b/packages/components/src/color-palette/index.tsx index de4e4f4206fe3a..65e244f604f7dc 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,77 @@ 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 ); + + 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 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 ); + } + }; + + if ( isEditing && isHex ) { + return ( + + ); + } + + return ( + + { value } + + ); +} + function SinglePalette( { className, clearColor, @@ -220,7 +304,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 +395,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 b2a7a884fec57d..c6f4446542e13a 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; +}; From 290668984b33b8903f3801329854bdf21857e133 Mon Sep 17 00:00:00 2001 From: dhruvikpatel18 Date: Tue, 18 Feb 2025 12:39:55 +0530 Subject: [PATCH 2/5] improved keyboard accessibility --- packages/components/src/color-palette/index.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/components/src/color-palette/index.tsx b/packages/components/src/color-palette/index.tsx index 65e244f604f7dc..625aa1283bd90b 100644 --- a/packages/components/src/color-palette/index.tsx +++ b/packages/components/src/color-palette/index.tsx @@ -63,6 +63,7 @@ function CustomColorValueInput( { const [ isEditing, setIsEditing ] = useState( false ); const [ inputValue, setInputValue ] = useState( value ); const inputRef = useRef< HTMLInputElement >( null ); + const truncateRef = useRef< HTMLDivElement >( null ); useEffect( () => { if ( isEditing && inputRef.current ) { @@ -81,6 +82,13 @@ function CustomColorValueInput( { 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 || '' ) ) { @@ -96,6 +104,7 @@ function CustomColorValueInput( { } else if ( e.key === 'Escape' ) { setInputValue( value ); setIsEditing( false ); + truncateRef.current?.focus(); } }; @@ -108,18 +117,24 @@ function CustomColorValueInput( { onBlur={ handleBlur } onKeyDown={ handleKeyDown } className="components-color-palette__custom-color-value-input" + aria-label={ __( 'Edit color hex value' ) } /> ); } return ( { value } From 1c78ef7f804cba82f73fcf13eb1169ac4014a2f7 Mon Sep 17 00:00:00 2001 From: dhruvikpatel18 Date: Tue, 18 Feb 2025 13:08:39 +0530 Subject: [PATCH 3/5] docs: Updated README doc file with mentioned features --- packages/components/src/color-palette/README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/components/src/color-palette/README.md b/packages/components/src/color-palette/README.md index 0fbfbd4d710013..2ffe183ca85486 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. From 605f706730b7b78288f92731ec15fa42d2b69f85 Mon Sep 17 00:00:00 2001 From: dhruvikpatel18 Date: Wed, 19 Feb 2025 10:45:25 +0530 Subject: [PATCH 4/5] Ensures the input field stays in sync with color changes --- packages/components/src/color-palette/index.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/components/src/color-palette/index.tsx b/packages/components/src/color-palette/index.tsx index 625aa1283bd90b..9c82ffc970701f 100644 --- a/packages/components/src/color-palette/index.tsx +++ b/packages/components/src/color-palette/index.tsx @@ -65,6 +65,10 @@ function CustomColorValueInput( { const inputRef = useRef< HTMLInputElement >( null ); const truncateRef = useRef< HTMLDivElement >( null ); + useEffect( () => { + setInputValue( value ); + }, [ value ] ); + useEffect( () => { if ( isEditing && inputRef.current ) { inputRef.current.focus(); @@ -111,6 +115,7 @@ function CustomColorValueInput( { if ( isEditing && isHex ) { return ( Date: Thu, 20 Feb 2025 11:36:54 +0530 Subject: [PATCH 5/5] Update unit tests and snapshots --- .../test/__snapshots__/control.js.snap | 5 +- .../components/color-palette/test/control.js | 121 +++++++++++++++++- 2 files changed, 124 insertions(+), 2 deletions(-) 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 3d082a14a92bff..8f02a338d1de2c 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 e1a5529544f791..62dd023266512e 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' ); + } ); } );