modules_Droplist_index.js

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;