UNPKG

@gravityforms/components

Version:

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

621 lines (590 loc) 18.2 kB
import { React, classnames, PropTypes } from '@gravityforms/libraries'; import { IdProvider, useIdContext, useFocusTrap, usePopup } from '@gravityforms/react-utils'; import Button from '../../elements/Button'; import Icon from '../../elements/Icon'; import Text from '../../elements/Text'; const { forwardRef, useState, useEffect, useRef } = React; /** * @function getListItems * @description Helper function to get droplist items. * * @since 4.3.0 * * @param {Array} items List of items to render. * @param {object} propsWithState Props and state to pass to the list items. * @param {number} depth Depth of the item. * * @return {Array} The list of items. */ const getListItems = ( items = [], propsWithState = {}, depth = 0 ) => { return items.map( ( item, index ) => { if ( item.listItems ) { const key = item.key || `${ propsWithState.droplistId }-group-${ depth }-${ index }`; const props = { depth, index, item, propsWithState }; return <DroplistGroupItem key={ key } { ...props } />; } const itemClassName = classnames( { 'gform-droplist__item': true, 'gform-droplist__item--has-divider': item.hasDivider, }, item.customClasses || [] ); const droplistItemProps = { ...( item.props || {} ), depth, propsWithState, }; const key = item.key || `${ propsWithState.droplistId }-group-${ depth }-${ index }`; return ( <li key={ key } className={ itemClassName }> <DroplistItem { ...droplistItemProps } /> </li> ); } ); }; /** * @module DroplistItem * @description The DroplistItem component. * * @since 4.3.0 * * @param {object} props Props for the DroplistItem component. * @param {object} props.customAttributes Custom attributes for the droplist item. * @param {string|Array|object} props.customClasses Custom classes for the droplist item. * @param {number} props.depth The depth of the droplist item. * @param {string} props.element The element type to render, one of `button` or `link`. * @param {string} props.iconAfter The icon after the text. * @param {object} props.iconAfterAttributes Custom attributes for the icon after the text. * @param {string|Array|object} props.iconAfterClasses Custom classes for the icon after the text. * @param {string} props.iconBefore The icon before the text. * @param {object} props.iconBeforeAttributes Custom attributes for the icon before the text. * @param {string|Array|object} props.iconBeforeClasses Custom classes for the icon before the text. * @param {string} props.iconPrefix The icon prefix to use. * @param {number} props.index The index of the droplist item. * @param {string} props.label The label to display. * @param {object} props.labelAttributes Custom attributes for the label component. * @param {string|Array|object} props.labelClasses Custom classes for the label component. * @param {object} props.propsWithState Props and state to pass to the droplist item. * @param {string} props.style The style of the droplist item, one of `info` or `error`. * @param {object|null} ref Ref to the component. * * @return {JSX.Element|null} The DroplistItem component. */ export const DroplistItem = forwardRef( ( { customAttributes = {}, customClasses = [], depth = 0, element = 'button', iconAfter = '', iconAfterAttributes = {}, iconAfterClasses = [], iconBefore = '', iconBeforeAttributes = {}, iconBeforeClasses = [], iconPrefix = 'gravity-component-icon', index = 0, label = '', labelAttributes = {}, labelClasses = [], propsWithState = {}, style = 'info', }, ref ) => { // Check if element type is valid, return null if not. if ( ! [ 'button', 'link' ].includes( element ) ) { return null; } const { openOnHover, selectedState, setSelectedState } = propsWithState; const setSelectedStateOpen = () => { const { id = '' } = customAttributes; // If the group is already open, do nothing. if ( selectedState[ depth ] === id ) { return; } const depthKeys = Object.keys( selectedState ); const filteredState = depthKeys .filter( ( key ) => key < depth ) .reduce( ( acc, key ) => { acc[ key ] = selectedState[ key ]; return acc; }, {} ); const newSelectedState = { ...filteredState, [ depth ]: id, }; setSelectedState( newSelectedState ); }; const triggerProps = { className: classnames( { 'gform-droplist__item-trigger': true, [ `gform-droplist__item-trigger--${ style }` ]: true, [ `gform-droplist__item-trigger--depth-${ depth }` ]: true, [ `gform-droplist__item-trigger--${ index }` ]: true, 'gform-droplist__item-trigger--disabled': element === 'button' && customAttributes.disabled, }, customClasses ), onMouseEnter: openOnHover ? () => { setSelectedStateOpen(); } : undefined, ...customAttributes, }; if ( propsWithState.closeOnClick ) { triggerProps.onClick = ( event ) => { const { onClick = () => {} } = customAttributes; onClick( event ); propsWithState.closeDroplist(); }; } const iconBeforeProps = { icon: iconBefore, iconPrefix, customClasses: classnames( { 'gform-droplist__item-trigger-icon': true, 'gform-droplist__item-trigger-icon--before': true, }, iconBeforeClasses ), ...iconBeforeAttributes, }; const iconAfterProps = { icon: iconAfter, iconPrefix, customClasses: classnames( { 'gform-droplist__item-trigger-icon': true, 'gform-droplist__item-trigger-icon--after': true, }, iconAfterClasses ), ...iconAfterAttributes, }; const labelProps = { content: label, customClasses: classnames( { 'gform-droplist__item-trigger-text': true, }, labelClasses ), color: style === 'error' ? 'red' : undefined, size: 'text-sm', ...labelAttributes, }; const Component = element === 'link' ? 'a' : element; return ( <Component ref={ ref } { ...triggerProps }> { iconBefore && <Icon { ...iconBeforeProps } /> } { label && <Text { ...labelProps } /> } { iconAfter && <Icon { ...iconAfterProps } /> } </Component> ); } ); DroplistItem.propTypes = { customAttributes: PropTypes.object, customClasses: PropTypes.oneOfType( [ PropTypes.string, PropTypes.array, PropTypes.object, ] ), depth: PropTypes.number, element: PropTypes.oneOf( [ 'button', 'link' ] ), iconAfter: PropTypes.string, iconAfterAttributes: PropTypes.object, iconAfterClasses: PropTypes.oneOfType( [ PropTypes.string, PropTypes.array, PropTypes.object, ] ), iconBefore: PropTypes.string, iconBeforeAttributes: PropTypes.object, iconBeforeClasses: PropTypes.oneOfType( [ PropTypes.string, PropTypes.array, PropTypes.object, ] ), iconPrefix: PropTypes.string, label: PropTypes.string, labelAttributes: PropTypes.object, labelClasses: PropTypes.oneOfType( [ PropTypes.string, PropTypes.array, PropTypes.object, ] ), propsWithState: PropTypes.object, style: PropTypes.oneOf( [ 'info', 'error' ] ), }; DroplistItem.displayName = 'DroplistItem'; /** * @module DroplistGroupItem * @description The DroplistGroupItem component. * * @since 4.3.0 * * @param {object} props Props for the DroplistGroupItem component. * @param {number} props.depth The depth of the item. * @param {number} props.index The index of the item. * @param {object} props.item The item object. * @param {object} props.propsWithState The props and state object. * @param {object|null} ref Ref to the component. * * @return {JSX.Element|null} The DroplistGroupItem component. */ export const DroplistGroupItem = forwardRef( ( { depth = 0, index = 0, item = {}, propsWithState = {}, }, ref ) => { const { droplistId, openOnHover, selectedState, setSelectedState } = propsWithState; const { onAfterClose = () => {}, onAfterOpen = () => {}, onClose = () => {}, onOpen = () => {}, } = item; const { customClasses: groupTriggerCustomClasses = [], id = `${ droplistId }-group-trigger-${ depth }-${ index }`, onClick = () => {}, ...restGroupTriggerAttributes } = item.triggerAttributes || {}; const { customClasses: groupListContainerCustomClasses = [], width = 0, ...restGroupListContainerAttributes } = item.listContainerAttributes || {}; const { closePopup, openPopup, popupHide, popupOpen, popupReveal, } = usePopup( { closeOnClickOutside: false, onAfterClose, onAfterOpen, onClose, onOpen, } ); /** * @function updateSelectedState * @description Updates the selected state. */ const updateSelectedState = () => { if ( popupOpen ) { setSelectedStateClosed(); } else { setSelectedStateOpen(); } }; /** * @function setSelectedStateOpen * @description Sets the selected state to open. */ const setSelectedStateOpen = () => { // If the group is already open, do nothing. if ( selectedState[ depth ] === id ) { return; } const depthKeys = Object.keys( selectedState ); const filteredState = depthKeys .filter( ( key ) => key < depth ) .reduce( ( acc, key ) => { acc[ key ] = selectedState[ key ]; return acc; }, {} ); const newSelectedState = { ...filteredState, [ depth ]: id, }; setSelectedState( newSelectedState ); }; /** * @function setSelectedStateClosed * @description Sets the selected state to closed. */ const setSelectedStateClosed = () => { const depthKeys = Object.keys( selectedState ); const filteredState = depthKeys .filter( ( key ) => key < depth ) .reduce( ( acc, key ) => { acc[ key ] = selectedState[ key ]; return acc; }, {} ); setSelectedState( filteredState ); }; useEffect( () => { if ( selectedState[ depth ] === id && ! popupOpen ) { openPopup(); } else if ( selectedState[ depth ] !== id && popupOpen ) { closePopup(); } }, [ selectedState, id, popupOpen ] ); const groupListItemProps = { className: classnames( { 'gform-droplist__item': true, 'gform-droplist__item--group': true, 'gform-droplist__item--open': popupOpen, 'gform-droplist__item--reveal': popupReveal, 'gform-droplist__item--hide': popupHide, 'gform-droplist__item--has-divider': item.hasDivider, }, item.customClasses || [] ), }; const groupTriggerProps = { customAttributes: { 'aria-expanded': popupOpen ? 'true' : 'false', 'aria-haspopup': 'listbox', id, onClick: ( event ) => { onClick( event ); updateSelectedState(); }, onMouseEnter: openOnHover ? () => { setSelectedStateOpen(); } : undefined, }, customClasses: classnames( 'gform-droplist__item-trigger', groupTriggerCustomClasses, ), depth, ...restGroupTriggerAttributes, }; const groupListContainerProps = { className: classnames( 'gform-droplist__list-container', groupListContainerCustomClasses, ), role: 'listbox', tabIndex: '-1', style: { width: width ? `${ width }px` : undefined, }, ...restGroupListContainerAttributes, }; return ( <li { ...groupListItemProps } ref={ ref }> <DroplistItem { ...groupTriggerProps } /> <div { ...groupListContainerProps }> <ul className="gform-droplist__list gform-droplist__list--grouped"> { getListItems( item.listItems, propsWithState, depth + 1 ) } </ul> </div> </li> ); } ); DroplistGroupItem.propTypes = { depth: PropTypes.number, item: PropTypes.object, propsWithState: PropTypes.object, }; DroplistGroupItem.displayName = 'DroplistGroupItem'; const DroplistComponent = forwardRef( ( props, ref ) => { // eslint-disable-line no-unused-vars const { align, closeOnClick, customAttributes, customClasses, droplistAttributes, listItems, onAfterClose, onAfterOpen, onClose, onOpen, openOnHover, triggerAttributes, width, } = props; const [ selectedState, setSelectedState ] = useState( {} ); const triggerRef = useRef( null ); const droplistRef = useRef( null ); const { closePopup, openPopup, handleEscKeyDown, popupHide, popupOpen, popupReveal, } = usePopup( { onAfterClose, onAfterOpen, onClose: () => { onClose(); setSelectedState( {} ); }, onOpen, popupRef: droplistRef, triggerRef, } ); const trapRef = useFocusTrap( popupOpen ); const id = useIdContext(); /* Wrapper props */ const wrapperProps = { className: classnames( { 'gform-droplist': true, [ `gform-droplist--align-${ align }` ]: true, 'gform-droplist--open': popupOpen, 'gform-droplist--reveal': popupReveal, 'gform-droplist--hide': popupHide, }, customClasses ), id, ...customAttributes, }; /* Trigger props */ const { ariaId: triggerAriaId = `${ id }-trigger-aria`, ariaText: triggerAriaText = '', customAttributes: triggerCustomAttributes = {}, customClasses: triggerCustomClasses = [], id: triggerId = `${ id }-trigger`, onClick: triggerOnClick = () => {}, onKeyDown: triggerOnKeyDown = () => {}, title: triggerTitle = '', ...restTriggerAttributes } = triggerAttributes; const triggerProps = { className: classnames( { 'gform-droplist__trigger': true, }, triggerCustomClasses || [] ), customAttributes: { 'aria-expanded': popupOpen ? 'true' : 'false', 'aria-haspopup': 'listbox', 'aria-labelledby': triggerTitle ? undefined : `${ triggerAriaId } ${ triggerId }`, id: triggerId, onKeyDown: ( event ) => { handleEscKeyDown( event ); triggerOnKeyDown( event ); }, title: triggerTitle || undefined, ...triggerCustomAttributes, }, ref: triggerRef, onClick: ( event ) => { triggerOnClick( event ); if ( popupOpen ) { closePopup(); } else { openPopup(); } }, size: 'size-height-m', type: 'white', ...restTriggerAttributes, }; /* Droplist props */ const { customClasses: droplistCustomClasses = [], onKeyDown: droplistOnKeyDown = () => {}, ...restDroplistAttributes } = droplistAttributes; const droplistProps = { className: classnames( { 'gform-droplist__list-wrapper': true, }, droplistCustomClasses ), 'aria-labelledby': triggerAriaId, role: 'listbox', tabIndex: '-1', ref: droplistRef, style: { width: width ? `${ width }px` : undefined, }, onKeyDown: ( event ) => { handleEscKeyDown( event ); droplistOnKeyDown( event ); }, ...restDroplistAttributes, }; return ( <div { ...wrapperProps } ref={ trapRef }> { triggerTitle ? null : ( <span className="gform-visually-hidden" id={ triggerAriaId } > { triggerAriaText } </span> ) } <Button { ...triggerProps } /> <div { ...droplistProps }> <div className="gform-droplist__list-container"> <ul className="gform-droplist__list"> { getListItems( listItems, { closeDroplist: closePopup, closeOnClick, droplistId: id, openOnHover, selectedState, setSelectedState, }, 0, ) } </ul> </div> </div> </div> ); } ); /** * @module Droplist * @description The Droplist component with id wrapper. * * @since 4.3.0 * * @param {object} props Props for the Droplist component. * @param {string} props.align The alignment of the droplist, one of `left` or `right`. * @param {boolean} props.closeOnClick Whether to close the droplist when an item is clicked. * @param {object} props.customAttributes The custom attributes for the droplist. * @param {string|Array|object} props.customClasses The custom classes for the droplist. * @param {object} props.droplistAttributes The droplist attributes. * @param {string} props.id The id of the droplist. * @param {Array} props.listItems The list items for the droplist. * @param {Function} props.onAfterClose The callback function to run after the droplist closes. * @param {Function} props.onAfterOpen The callback function to run after the droplist opens. * @param {Function} props.onClose The callback function to run when the droplist closes. * @param {Function} props.onOpen The callback function to run when the droplist opens. * @param {boolean} props.openOnHover Whether to open sublists on hover. * @param {object} props.triggerAttributes The trigger attributes for the droplist. * @param {number} props.width The width of the droplist. * @param {object} ref The ref for the droplist. * * @return {JSX.Element} The Droplist component. */ const Droplist = forwardRef( ( props, ref ) => { const defaultProps = { align: 'left', closeOnClick: false, customAttributes: {}, customClasses: [], droplistAttributes: {}, id: '', listItems: [], onAfterClose: () => {}, onAfterOpen: () => {}, onClose: () => {}, onOpen: () => {}, openOnHover: false, triggerAttributes: {}, width: 0, }; const combinedProps = { ...defaultProps, ...props }; const { id: idProp } = combinedProps; const idProviderProps = { id: idProp }; return ( <IdProvider { ...idProviderProps }> <DroplistComponent { ...combinedProps } ref={ ref } /> </IdProvider> ); } ); Droplist.propTypes = { align: PropTypes.oneOf( [ 'left', 'right' ] ), // autoPosition: PropTypes.bool, @todo: Implement this feature. closeOnClick: PropTypes.bool, customAttributes: PropTypes.object, customClasses: PropTypes.oneOfType( [ PropTypes.string, PropTypes.array, PropTypes.object, ] ), droplistAttributes: PropTypes.object, id: PropTypes.string, listItems: PropTypes.array, onAfterClose: PropTypes.func, onAfterOpen: PropTypes.func, onClose: PropTypes.func, onOpen: PropTypes.func, openOnHover: PropTypes.bool, triggerAttributes: PropTypes.object, width: PropTypes.number, }; Droplist.displayName = 'Droplist'; export default Droplist;