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;