import { React, classnames, PropTypes } from '@gravityforms/libraries';
import { useFocusTrap } from '@gravityforms/react-utils';
import { uniqueId } from '@gravityforms/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 ) => {
if ( item.listItems ) {
const props = { depth, item, propsWithState };
return <DroplistGroupItem key={ item.key } { ...props } />;
}
const itemClassName = classnames( {
'gform-droplist__item': true,
'gform-droplist__item--has-divider': item.hasDivider,
}, item.customClasses || [] );
const droplistItemProps = {
...( item.props || {} ),
depth,
propsWithState,
};
return (
<li key={ item.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.icon The icon to display.
* @param {object} props.iconAttributes Custom attributes for the icon component.
* @param {string|Array|object} props.iconClasses Custom classes for the icon component.
* @param {string} props.iconPrefix The icon prefix to use.
* @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',
groupIcon = '',
icon = '',
iconAttributes = {},
iconClasses = [],
iconPrefix = 'gform-icon',
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 hasGroup = !! customAttributes[ 'aria-haspopup' ];
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--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 iconProps = {
icon,
iconPrefix,
customClasses: classnames( {
'gform-droplist__item-trigger-icon': true,
}, iconClasses ),
...iconAttributes,
};
const groupIconProps = {
icon: groupIcon,
iconPrefix,
customClasses: [ 'gform-droplist__item-trigger-group-icon' ],
};
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 }>
{ icon && <Icon { ...iconProps } /> }
{ label && <Text { ...labelProps } /> }
{ hasGroup && groupIcon && <Icon { ...groupIconProps } /> }
</Component>
);
} );
DroplistItem.propTypes = {
customAttributes: PropTypes.object,
customClasses: PropTypes.oneOfType( [
PropTypes.string,
PropTypes.array,
PropTypes.object,
] ),
depth: PropTypes.number,
element: PropTypes.oneOf( [ 'button', 'link' ] ),
icon: PropTypes.string,
iconAttributes: PropTypes.object,
iconClasses: 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 {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,
item = {},
propsWithState = {},
}, ref ) => {
const [ groupReveal, setGroupReveal ] = useState( false );
const [ groupHide, setGroupHide ] = useState( false );
const [ groupOpen, setGroupOpen ] = useState( false );
const {
onAfterClose = () => {},
onAfterOpen = () => {},
onClose = () => {},
onOpen = () => {},
} = item;
const {
customClasses: groupTriggerCustomClasses = [],
id = uniqueId( 'droplist-group-trigger' ),
onClick = () => {},
...restGroupTriggerAttributes
} = item.triggerAttributes || {};
const {
customClasses: groupListContainerCustomClasses = [],
width = 0,
...restGroupListContainerAttributes
} = item.listContainerAttributes || {};
const { openOnHover, selectedState, setSelectedState } = propsWithState;
/**
* @function openGroup
* @description Opens the group.
*/
const openGroup = () => {
onOpen();
setGroupReveal( true );
requestAnimationFrame( () => {
setGroupOpen( true );
setTimeout( () => {
setGroupReveal( false );
onAfterOpen();
}, 150 );
} );
};
/**
* @function closeGroup
* @description Closes the group.
*/
const closeGroup = () => {
onClose();
setGroupOpen( false );
setGroupHide( true );
setTimeout( () => {
setGroupHide( false );
onAfterClose();
}, 150 );
};
/**
* @function updateSelectedState
* @description Updates the selected state.
*/
const updateSelectedState = () => {
if ( groupOpen ) {
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 ) {
openGroup();
} else {
closeGroup();
}
}, [ selectedState, id ] );
const groupListItemProps = {
className: classnames( {
'gform-droplist__item': true,
'gform-droplist__item--group': true,
'gform-droplist__item--open': groupOpen,
'gform-droplist__item--reveal': groupReveal,
'gform-droplist__item--hide': groupHide,
'gform-droplist__item--has-divider': item.hasDivider,
} ),
};
const groupTriggerProps = {
customAttributes: {
'aria-expanded': groupOpen ? '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';
/**
* @module Droplist
* @description The Droplist component.
*
* @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 {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 {React.ReactElement} The Droplist component.
*/
const Droplist = forwardRef( ( {
align = 'left',
// autoPosition = false, @todo: Implement this feature
closeOnClick = false,
customAttributes = {},
customClasses = [],
droplistAttributes = {},
listItems = [],
onAfterClose = () => {},
onAfterOpen = () => {},
onClose = () => {},
onOpen = () => {},
openOnHover = false,
triggerAttributes = {},
width = 0,
}, ref ) => { // eslint-disable-line no-unused-vars
const [ droplistReveal, setDroplistReveal ] = useState( false );
const [ droplistHide, setDroplistHide ] = useState( false );
const [ droplistOpen, setDroplistOpen ] = useState( false );
const [ selectedState, setSelectedState ] = useState( {} );
const trapRef = useFocusTrap( droplistOpen );
const triggerRef = useRef( null );
const droplistRef = useRef( null );
/**
* @function openDroplist
* @description Opens the droplist.
*/
const openDroplist = () => {
onOpen();
setDroplistReveal( true );
requestAnimationFrame( () => {
// setSmartPosition(); @todo: Implement this function.
requestAnimationFrame( () => {
setDroplistOpen( true );
setTimeout( () => {
setDroplistReveal( false );
onAfterOpen();
}, 150 );
} );
} );
};
/**
* @function closeDroplist
* @description Closes the droplist.
*/
const closeDroplist = () => {
onClose();
setDroplistOpen( false );
setDroplistHide( true );
setSelectedState( {} );
setTimeout( () => {
setDroplistHide( false );
onAfterClose();
}, 150 );
};
useEffect( () => {
const handleClickOutside = ( event ) => {
// If refs don't exist, return early.
if ( ! droplistOpen || ! triggerRef.current || ! droplistRef.current ) {
return;
}
if (
! triggerRef.current.contains( event.target ) &&
! droplistRef.current.contains( event.target )
) {
closeDroplist();
}
};
document.addEventListener( 'click', handleClickOutside );
return () => {
document.removeEventListener( 'click', handleClickOutside );
};
}, [ droplistOpen, triggerRef, droplistRef ] );
/* Wrapper props */
const wrapperProps = {
className: classnames( {
'gform-droplist': true,
[ `gform-droplist--align-${ align }` ]: true,
// 'gform-droplist--position-top': position === 'top', @todo: Implement this feature.
'gform-droplist--open': droplistOpen,
'gform-droplist--reveal': droplistReveal,
'gform-droplist--hide': droplistHide,
}, customClasses ),
...customAttributes,
};
/* Trigger props */
const {
ariaId: triggerAriaId = uniqueId( 'droplist-trigger-aria' ),
ariaText: triggerAriaText = '',
customAttributes: triggerCustomAttributes = {},
customClasses: triggerCustomClasses = [],
id: triggerId = uniqueId( 'droplist-trigger' ),
onClick: triggerOnClick = () => {},
title: triggerTitle = '',
...restTriggerAttributes
} = triggerAttributes;
const triggerProps = {
className: classnames( {
'gform-droplist__trigger': true,
}, triggerCustomClasses || [] ),
customAttributes: {
'aria-expanded': droplistOpen ? 'true' : 'false',
'aria-haspopup': 'listbox',
'aria-labelledby': triggerTitle ? undefined : `${ triggerAriaId } ${ triggerId }`,
id: triggerId,
title: triggerTitle || undefined,
...triggerCustomAttributes,
},
ref: triggerRef,
onClick: ( event ) => {
triggerOnClick( event );
if ( droplistOpen ) {
closeDroplist();
} else {
openDroplist();
}
},
size: 'size-height-m',
type: 'white',
...restTriggerAttributes,
};
/* Droplist props */
const {
customClasses: droplistCustomClasses = [],
...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,
},
...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,
closeOnClick,
openOnHover,
selectedState,
setSelectedState,
},
0,
) }
</ul>
</div>
</div>
</div>
);
} );
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,
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;