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;