modules_Droplist_index.js

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;