modules_Swatch_index.js

import { React, PropTypes, classnames } from '@gravityforms/libraries';
import { spacerClasses } from '@gravityforms/utils';
import { useStateWithDep, IdProvider, useIdContext } from '@gravityforms/react-utils';
import Button from '../../elements/Button';
import Icon from '../../elements/Icon';
import ColorPicker from '../ColorPicker';
import { invertColor } from '../../utils/colors';
import { ENTER, SPACE, ARROW_UP, ARROW_DOWN, ARROW_LEFT, ARROW_RIGHT, PAGE_UP, PAGE_DOWN, HOME, END, DELETE, BACKSPACE } from '../../utils/keymap';

const { useState, useEffect, useRef, forwardRef } = React;

const NEEDS_I18N_LABEL = 'Needs i18n';

const ADD = 'add';
const UPDATE = 'update';

const SwatchComponent = forwardRef( ( props, ref ) => {
	const {
		allowNew = true,
		colorPickerAttributes = {},
		colorPickerClasses = [],
		controlled = false,
		customAttributes = {},
		customClasses = [],
		i18n = {},
		iconPrefix = 'gravity-component-icon',
		onChange = () => {},
		palette = [],
		paletteCustom = [],
		spacing = '',
		value = '',
	} = props;
	const id = useIdContext();
	const name = props?.name || id;

	const [ showPicker, setShowPicker ] = useState( false );
	const [ selectedColor, setSelectedColor ] = useState( value );
	const [ controlledSelectedColor, setControlledSelectedColor ] = useStateWithDep( value );
	const [ focusedOption, setFocusedOption ] = useState( null );
	const [ color, setColor ] = useState( value );
	const [ customColorBeingModified, setCustomColorBeingModified ] = useState( paletteCustom.length );
	const [ customPaletteOptions, setCustomPaletteOptions ] = useState( paletteCustom );
	const [ colorPickerAction, setColorPickerAction ] = useState( ADD );
	const [ currentRef, setCurrentRef ] = useState( null );

	const addNewRef = useRef();
	const listRef = useRef();
	const swatchRefs = useRef( [] );

	const allPalette = [
		...palette.map( ( swatch ) => ( { swatch } ) ),
		...customPaletteOptions.map( ( swatch, index ) => ( { swatch, isCustom: true, customIndex: index } ) ),
	];

	const [ colorBeingModified, setColorBeingModified ] = useState( allPalette.length );

	useEffect( () => {
		if ( ! currentRef || ! currentRef.current ) {
			setCurrentRef( addNewRef );
		}
	}, [ setCurrentRef, currentRef ] );

	/**
	 * @function focusSwatch
	 * @description Focus the swatch at the given index if it exists.
	 *
	 * @since 6.0.20
	 *
	 * @param {number|null} index Index of the swatch to focus. If null, focus on the list.
	 *
	 * @return {void}
	 */
	const focusSwatch = ( index ) => {
		if ( index === null ) {
			setFocusedOption( null );
			listRef.current.focus();
			return;
		}
		if ( swatchRefs.current[ index ] ) {
			setFocusedOption( index );
			swatchRefs.current[ index ].focus();
		}
	};

	/**
	 * @function handlePickerCancel
	 * @description Handler for the cancel event on the swatch.
	 *
	 * @since 1.1.15
	 *
	 * @return {void}
	 */
	const handlePickerCancel = () => {
		setShowPicker( false );
	};

	/**
	 * @function handlePickerDelete
	 * @description Handler for the delete event on the swatch.
	 *
	 * @since 1.1.15
	 *
	 * @param {number} customIndex Index of the custom swatch palette.
	 * @param {number} allIndex Index of the all palette.
	 *
	 * @return {void}
	 */
	const handlePickerDelete = ( customIndex, allIndex ) => {
		const currentColor = controlled ? controlledSelectedColor : selectedColor;
		const hasNextIndex = allIndex + 1 < allPalette.length;
		const isFirstIndex = allIndex === 0;
		if ( currentColor === allPalette[ allIndex ].swatch ) {
			let nextIndex = hasNextIndex ? allIndex + 1 : allIndex - 1;
			nextIndex = isFirstIndex ? null : nextIndex;
			if ( nextIndex !== null ) {
				handleColorChange( allPalette[ nextIndex ].swatch );
			}
		}
		setTimeout( () => {
			// Setting to allIndex since we are deleting the current swatch, swatch at allIndex + 1 will become allIndex.
			let focusIndex = hasNextIndex ? allIndex : allIndex - 1;
			focusIndex = isFirstIndex ? null : focusIndex;
			focusSwatch( focusIndex );
		} );
		setCustomPaletteOptions( ( prevPalette ) => prevPalette.filter( ( item, thisIndex ) => thisIndex !== customIndex ) );
		handlePickerCancel();
	};

	/**
	 * @function handlePickerSave
	 * @description Handler for the save event on the swatch.
	 *
	 * @since 1.1.15
	 *
	 * @param {string} swatch The swatch value.
	 *
	 * @return {void}
	 */
	const handlePickerSave = ( swatch ) => {
		setColor( swatch );
		if ( ! [ ...palette, ...customPaletteOptions ].includes( swatch ) ) {
			setCustomPaletteOptions( ( prevPalette ) => {
				const newPalette = prevPalette;
				newPalette[ customColorBeingModified ] = swatch;
				return newPalette;
			} );
		}
		setSelectedColor( swatch );
		setControlledSelectedColor( swatch );
		if ( colorPickerAction === ADD ) {
			setTimeout( () => {
				focusSwatch( allPalette.length );
			} );
		} else {
			focusSwatch( colorBeingModified );
		}
		setShowPicker( false );
	};

	/**
	 * @function handleColorChange
	 * @description Handler for the color change event.
	 *
	 * @since 1.1.15
	 *
	 * @param {string} swatch The swatch value.
	 *
	 * @return {void}
	 */
	const handleColorChange = ( swatch ) => {
		setSelectedColor( swatch );
		setControlledSelectedColor( swatch );
		onChange( swatch );
	};

	/**
	 * @function renderSwatchOption
	 * @description Render the swatch option.
	 *
	 * @since 1.1.15
	 *
	 * @param {string}       swatch      The swatch value.
	 * @param {number}       index       The index of the swatch palette.
	 * @param {boolean}      isCustom    Whether the swatch is custom or not.
	 * @param {number|null}  customIndex The index of the custom swatch, if any.
	 *
	 * @return {JSX.Element} The swatch option.
	 */
	const renderSwatchOption = ( swatch, index, isCustom = false, customIndex = null ) => {
		const liProps = {
			className: 'gform-swatch__option',
			'data-value': swatch,
			key: `${ id }-swatch-option-${ swatch.replace( '#', '' ) }`,
			role: 'option',
			tabIndex: focusedOption === index ? '0' : '-1',
			onClick: () => {
				handleColorChange( swatch );
				if ( isCustom ) {
					setColor( swatch );
					setCurrentRef( { current: swatchRefs.current[ index ] } );
					setColorBeingModified( index );
					setCustomColorBeingModified( customIndex );
					setColorPickerAction( UPDATE );
					setShowPicker( true );
				} else {
					setShowPicker( false );
				}
			},
			onFocus: ( event ) => {
				event.stopPropagation();
				setFocusedOption( index );
			},
			onKeyDown: ( event ) => {
				if ( ! [ ENTER, SPACE, ARROW_UP, ARROW_DOWN, ARROW_LEFT, ARROW_RIGHT, PAGE_UP, PAGE_DOWN, HOME, END, DELETE, BACKSPACE ].includes( event.key ) ) {
					return;
				}

				event.preventDefault();

				if ( [ ENTER, SPACE ].includes( event.key ) ) {
					handleColorChange( swatch );
					if ( isCustom ) {
						setColor( swatch );
						setCurrentRef( { current: swatchRefs.current[ index ] } );
						setColorBeingModified( index );
						setCustomColorBeingModified( customIndex );
						setColorPickerAction( UPDATE );
						setShowPicker( true );
					} else {
						setShowPicker( false );
					}
					return;
				}
				if ( [ ARROW_UP, ARROW_DOWN, ARROW_LEFT, ARROW_RIGHT ].includes( event.key ) ) {
					let nextIndex;
					if ( [ ARROW_UP, ARROW_LEFT ].includes( event.key ) ) {
						nextIndex = index - 1 > 0 ? index - 1 : 0;
					}
					if ( [ ARROW_DOWN, ARROW_RIGHT ].includes( event.key ) ) {
						nextIndex = index + 1 < allPalette.length - 1 ? index + 1 : allPalette.length - 1;
					}

					focusSwatch( nextIndex );
					return;
				}
				if ( [ PAGE_UP, PAGE_DOWN, HOME, END ].includes( event.key ) ) {
					let nextIndex;
					if ( [ PAGE_UP, HOME ].includes( event.key ) ) {
						nextIndex = 0;
					}
					if ( [ PAGE_DOWN, END ].includes( event.key ) ) {
						nextIndex = allPalette.length - 1;
					}

					focusSwatch( nextIndex );
					return;
				}
				if ( [ DELETE, BACKSPACE ].includes( event.key ) && isCustom ) {
					handlePickerDelete( customIndex, index );
				}
			},
			ref: ( el ) => ( swatchRefs.current[ index ] = el ),
		};

		if ( controlled ? swatch === controlledSelectedColor : swatch === selectedColor ) {
			liProps[ 'aria-selected' ] = true;
		}
		if ( isCustom ) {
			liProps[ 'aria-keyshortcuts' ] = 'Backspace Delete';
		}

		const swatchSpanProps = {
			className: classnames( {
				'gform-swatch__option-preview': true,
			} ),
			role: 'img',
			'aria-roledescription': i18n?.colorSwatch || NEEDS_I18N_LABEL,
			'aria-label': swatch,
			style: {
				backgroundColor: swatch,
			},
		};

		const invertedColor = invertColor( swatch );
		const iconProps = {
			icon: 'check',
			iconPrefix,
			customClasses: classnames( {
				'gform-swatch__option-icon': true,
				'gform-swatch__option-icon--selected': true,
			} ),
			customAttributes: {
				style: {
					color: invertedColor === '#FFFFFF' ? invertedColor : '#242748', // Set dark check color to port.
				},
			},
		};

		const deleteIconProps = {
			customAttributes: {
				onClick: ( event ) => {
					event.stopPropagation();
					handlePickerDelete( customIndex, index );
				},
			},
			icon: 'delete',
			iconPrefix,
			customClasses: classnames( {
				'gform-swatch__option-icon': true,
				'gform-swatch__option-icon--delete': true,
			} ),
		};

		return (
			<li { ...liProps }>
				<span { ...swatchSpanProps } >
					{ ( controlled ? swatch === controlledSelectedColor : swatch === selectedColor ) && <Icon { ...iconProps } /> }
					{ isCustom && <Icon { ...deleteIconProps } /> }
				</span>
			</li>
		);
	};

	/**
	 * @function renderAddNewSwatchOption
	 * @description Render the add new swatch option.
	 *
	 * @since 1.1.15
	 *
	 * @return {JSX.Element} The add new swatch option.
	 */
	const renderAddNewSwatchOption = () => {
		const buttonProps = {
			customAttributes: {
				type: 'button',
			},
			customClasses: [ 'gform-swatch__new' ],
			circular: true,
			onClick: () => {
				setCurrentRef( addNewRef );
				setColorBeingModified( allPalette.length );
				setCustomColorBeingModified( customPaletteOptions.length );
				setColorPickerAction( ADD );
				setShowPicker( true );
			},
			type: 'unstyled',
			ref: addNewRef,
		};

		// @todo: add new button label.
		const swatchSpanProps = {
			className: 'gform-swatch__new-preview',
		};

		const iconProps = {
			icon: 'plus-regular',
			iconPrefix,
			customClasses: [ 'gform-swatch__new-icon' ],
		};

		return (
			<Button { ...buttonProps }>
				{ i18n?.addNewSwatch ? <span className="gform-visually-hidden">{ i18n?.addNewSwatch }</span> : null }
				<span { ...swatchSpanProps } >
					<Icon { ...iconProps } />
				</span>
			</Button>
		);
	};

	const componentProps = {
		className: classnames( {
			'gform-swatch': true,
			...spacerClasses( spacing ),
		}, customClasses ),
		id,
		'data-js-setting-name': name,
		...customAttributes,
	};

	const swatchOptionsProps = {
		className: classnames( {
			'gform-swatch__options': true,
		} ),
		'aria-label': i18n?.swatchOptions || NEEDS_I18N_LABEL,
		id: `${ id }-swatch-options`,
		role: 'listbox',
		tabIndex: focusedOption === null ? '0' : '-1',
		onFocus: () => {
			if ( focusedOption !== null ) {
				return;
			}
			focusSwatch( 0 );
		},
		ref: listRef,
	};

	const pickerProps = {
		controlled: true,
		value: color || '#ffffff',
		onSave: handlePickerSave,
		onCancel: handlePickerCancel,
		triggerRef: currentRef,
		i18n: i18n?.colorPicker || {},
		...colorPickerAttributes,
		customClasses: colorPickerClasses,
	};

	return (
		<div { ...componentProps } ref={ ref }>
			{ allPalette.length > 0 && (
				<ul { ...swatchOptionsProps }>
					{ allPalette.map( ( item, index ) => renderSwatchOption( item.swatch, index, item.isCustom, item.customIndex ) ) }
				</ul>
			) }
			{ allowNew && renderAddNewSwatchOption() }
			{ showPicker && <ColorPicker { ...pickerProps } /> }
		</div>
	);
} );

/**
 * @module Swatch
 * @description Renders a swatch component with id wrapper, allows users to select from a palette of swatches, or add their own using a color picker.
 *
 * @since 1.1.15
 *
 * @param {object}                     props                       Component props.
 * @param {boolean}                    props.allowNew              Whether to display an icon to add new swatches to the palette.
 * @param {object}                     props.colorPickerAttributes Custom attributes for the color picker.
 * @param {string|Array|object}        props.colorPickerClasses    Custom classes for the color picker.
 * @param {boolean}                    props.controlled            Whether the component is controlled or not.
 * @param {object}                     props.customAttributes      Custom attributes for the component.
 * @param {string|Array|object}        props.customClasses         Custom classes for the component.
 * @param {object}                     props.i18n                  Translated strings for the UI.
 * @param {string}                     props.iconPrefix            The prefix for which icon kit to use.
 * @param {string}                     props.id                    The ID for the component.
 * @param {string}                     props.name                  The name of the component.
 * @param {Function}                   props.onChange              Callback function when the swatch value changes.
 * @param {Array}                      props.palette               An array of hex color values to display as default palette options.
 * @param {Array}                      props.paletteCustom         An array of hex color values to display as custom/editable palette options.
 * @param {string|number|Array|object} props.spacing               The spacing for the component, as a string, number, array, or object.
 * @param {string}                     props.value                 The initial hex value to select.
 * @param {object|null}                ref                         Ref to the component.
 *
 * @return {JSX.Element} The Swatch component.
 *
 * @example
 * import Swatch from '@gravityforms/components/react/admin/modules/Swatch';
 *
 * return (
 *     <Swatch customClasses={ [ 'example-class' ] } palette={ [ '#000', '#111' ] } />
 * );
 *
 */
const Swatch = forwardRef( ( props, ref ) => {
	const defaultProps = {
		allowNew: true,
		colorPickerAttributes: {},
		colorPickerClasses: [],
		controlled: false,
		customAttributes: {},
		customClasses: [],
		i18n: {},
		iconPrefix: 'gravity-component-icon',
		id: '',
		name: '',
		onChange: () => {},
		palette: [],
		paletteCustom: [],
		spacing: '',
		value: '',
	};
	const combinedProps = { ...defaultProps, ...props };
	const { id: idProp = '' } = combinedProps;
	const idProviderProps = { id: idProp };

	return (
		<IdProvider { ...idProviderProps }>
			<SwatchComponent { ...combinedProps } ref={ ref } />
		</IdProvider>
	);
} );

Swatch.propTypes = {
	allowNew: PropTypes.bool,
	colorPickerAttributes: PropTypes.object,
	colorPickerClasses: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.array,
		PropTypes.object,
	] ),
	controlled: PropTypes.bool,
	customAttributes: PropTypes.object,
	customClasses: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.array,
		PropTypes.object,
	] ),
	i18n: PropTypes.object,
	iconPrefix: PropTypes.string,
	id: PropTypes.string,
	name: PropTypes.string,
	onChange: PropTypes.func,
	palette: PropTypes.array,
	paletteCustom: PropTypes.array,
	spacing: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.number,
		PropTypes.array,
		PropTypes.object,
	] ),
	value: PropTypes.string,
};

Swatch.displayName = 'Swatch';

export default Swatch;