UNPKG

@gravityforms/components

Version:

UI components for use in Gravity Forms development. Both React and vanilla js flavors.

519 lines (481 loc) 15.6 kB
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;