elements_Button_MultiOptionButton_index.js

import { React, classnames, PropTypes } from '@gravityforms/libraries';
import { useId, usePopup } from '@gravityforms/react-utils';
import { spacerClasses } from '@gravityforms/utils';
import Button from '../index';
import Popover from '../../Popover';

const { forwardRef, useCallback, useRef } = React;

/**
 * @description Sets a forwarded ref value.
 *
 * @since 6.6.2
 *
 * @param {Function|object|null} forwardedRef The forwarded ref.
 * @param {HTMLElement|null}     node         The node to assign.
 *
 * @return {void}
 */
const setForwardedRef = ( forwardedRef, node ) => {
	if ( typeof forwardedRef === 'function' ) {
		forwardedRef( node );
	} else if ( forwardedRef ) {
		forwardedRef.current = node;
	}
};

/**
 * @module MultiOptionButton
 * @description A split button with a main action and attached action menu.
 *
 * @since 6.6.2
 *
 * @param {object}                     props                      Component props.
 * @param {boolean}                    props.active               Whether the main button is active or not.
 * @param {string}                     props.activeText           The active text when the main button is active.
 * @param {string}                     props.activeType           The active type, currently supports `loader`.
 * @param {string}                     props.ariaLabel            The aria-label text for the main button.
 * @param {JSX.Element|null}           props.children             React element children for the main button.
 * @param {boolean}                    props.circular             Whether the main button is a circular shape or not.
 * @param {boolean}                    props.closeOnOptionClick   Whether to close the menu when an option is clicked.
 * @param {object}                     props.customAttributes     Custom attributes for the wrapper.
 * @param {string|Array|object}        props.customClasses        Custom classes for the wrapper.
 * @param {boolean}                    props.disabled             Whether the multi option button is disabled or not.
 * @param {boolean}                    props.disableWhileActive   Whether to disable the main button while active.
 * @param {string}                     props.dropdownAriaLabel    The aria-label text for the dropdown trigger.
 * @param {string}                     props.dropdownIcon         Icon name for the dropdown trigger.
 * @param {string}                     props.dropdownIconPrefix   The prefix for the dropdown trigger icon library.
 * @param {object}                     props.iconAttributes       Custom attributes for the main button icon.
 * @param {string}                     props.icon                 Icon name if using an icon button.
 * @param {string}                     props.iconPosition         Icon position if using one, `leading` or `trailing`.
 * @param {string}                     props.iconPrefix           The prefix for the icon library to be used.
 * @param {string}                     props.label                The label for the main button.
 * @param {object}                     props.loaderProps          All valid options for the loader component if loader button is active.
 * @param {boolean}                    props.lockSize             Whether to lock the main button size when transitioning states.
 * @param {object}                     props.mainButtonAttributes Additional props for the main button.
 * @param {string|Array|object}        props.mainButtonClasses    Custom classes for the main button.
 * @param {Function}                   props.onClick              On click handler for the main button.
 * @param {Function}                   props.onDropdownClose      Callback fired when the dropdown menu closes.
 * @param {Function}                   props.onDropdownOpen       Callback fired when the dropdown menu opens.
 * @param {Array}                      props.options              The dropdown action options.
 * @param {object}                     props.popoverAttributes    Additional props for the Popover.
 * @param {string|Array|object}        props.popoverClasses       Custom classes for the Popover element.
 * @param {string}                     props.size                 Size of the main button and dropdown options.
 * @param {string|number|Array|object} props.spacing              The spacing for the wrapper component.
 * @param {object}                     props.triggerAttributes    Additional props for the dropdown trigger.
 * @param {string|Array|object}        props.triggerClasses       Custom classes for the dropdown trigger.
 * @param {string}                     props.type                 The visual type of the main button and dropdown options.
 * @param {string}                     props.width                The main button width, `auto` or `full`.
 * @param {object|null}                ref                        Ref to the wrapper.
 *
 * @return {JSX.Element} The multi option button component.
 */
const MultiOptionButton = forwardRef( ( {
	active = false,
	activeText = '',
	activeType = '',
	ariaLabel = '',
	children = null,
	circular = false,
	closeOnOptionClick = true,
	customAttributes = {},
	customClasses = [],
	disabled = false,
	disableWhileActive = true,
	dropdownAriaLabel = '',
	dropdownIcon = 'chevron-down',
	dropdownIconPrefix = 'gravity-component-icon',
	icon = '',
	iconAttributes = {},
	iconPosition = '',
	iconPrefix = 'gravity-component-icon',
	label = '',
	loaderProps = {
		customClasses: 'gform-button__loader',
		lineWeight: 2,
		size: 16,
	},
	lockSize = false,
	mainButtonAttributes = {},
	mainButtonClasses = [],
	onClick = () => {},
	onDropdownClose = () => {},
	onDropdownOpen = () => {},
	options = [],
	popoverAttributes = {},
	popoverClasses = [],
	size = 'size-r',
	spacing = '',
	triggerAttributes = {},
	triggerClasses = [],
	type = 'primary-new',
	width = 'auto',
}, ref ) => {
	const id = useId( customAttributes.id );
	const wrapperRef = useRef( null );
	const {
		closePopup,
		handleEscKeyDown,
		openPopup,
		popupHide,
		popupOpen,
		popupReveal,
		popupRef,
		triggerRef,
	} = usePopup( {
		onClose: onDropdownClose,
		onOpen: onDropdownOpen,
	} );

	/**
	 * @function handleWrapperRef
	 * @description Assigns the wrapper node to internal and forwarded refs.
	 *
	 * @since 6.6.2
	 *
	 * @param {HTMLElement|null} node The wrapper node.
	 *
	 * @return {void}
	 */
	const handleWrapperRef = useCallback( ( node ) => {
		wrapperRef.current = node;
		setForwardedRef( ref, node );
	}, [ ref ] );

	const {
		customAttributes: mainButtonCustomAttributes = {},
		...restMainButtonAttributes
	} = mainButtonAttributes;
	const {
		customAttributes: triggerCustomAttributes = {},
		onClick: triggerOnClick = () => {},
		...restTriggerAttributes
	} = triggerAttributes;
	const {
		customAttributes: popoverCustomAttributes = {},
		...restPopoverAttributes
	} = popoverAttributes;
	const anyButtonActive = active || options.some( ( option ) => option.active );
	const disabledWhileActive = disableWhileActive && anyButtonActive;
	const componentDisabled = disabled || disabledWhileActive;

	const wrapperProps = {
		id,
		...customAttributes,
		className: classnames( {
			'gform-multi-option-button': true,
			'gform-multi-option-button--open': popupOpen,
			'gform-multi-option-button--disabled': componentDisabled,
			[ `gform-multi-option-button--${ type }` ]: type,
			[ `gform-multi-option-button--${ size }` ]: size,
			[ `gform-multi-option-button--width-${ width }` ]: width,
			...spacerClasses( spacing ),
		}, customClasses ),
	};
	const popoverId = `${ id }-popover`;
	const triggerId = `${ id }-trigger`;
	const optionButtonDefaults = {
		circular: false,
		disabled: componentDisabled,
		disableWhileActive,
		iconPrefix,
		loaderProps,
		lockSize,
		size,
		type,
		width,
	};
	if ( activeType ) {
		optionButtonDefaults.activeType = activeType;
	}
	const triggerButtonProps = {
		ariaLabel: dropdownAriaLabel,
		disabled: componentDisabled,
		icon: dropdownIcon,
		iconPrefix: dropdownIconPrefix,
		size,
		type,
		...restTriggerAttributes,
		customAttributes: {
			'aria-controls': popoverId,
			'aria-expanded': popupOpen ? 'true' : 'false',
			'aria-haspopup': 'menu',
			id: triggerId,
			onKeyDown: handleEscKeyDown,
			type: 'button',
			...triggerCustomAttributes,
		},
		customClasses: classnames( {
			'gform-multi-option-button__trigger': true,
		}, triggerClasses ),
		onClick: ( event ) => {
			if ( componentDisabled ) {
				return;
			}

			if ( popupOpen ) {
				closePopup();
			} else {
				openPopup();
			}

			triggerOnClick( event );
		},
	};
	const mainButtonProps = {
		active,
		activeText,
		ariaLabel,
		circular,
		disabled: componentDisabled,
		disableWhileActive,
		icon,
		iconAttributes,
		iconPrefix,
		label,
		loaderProps,
		lockSize,
		onClick,
		size,
		type,
		width,
		...restMainButtonAttributes,
		customAttributes: {
			type: 'button',
			...mainButtonCustomAttributes,
		},
		customClasses: classnames( 'gform-multi-option-button__main', mainButtonClasses ),
	};
	if ( activeType ) {
		mainButtonProps.activeType = activeType;
	}
	if ( iconPosition ) {
		mainButtonProps.iconPosition = iconPosition;
	}
	const popoverProps = {
		align: 'right',
		autoPlacement: true,
		containerRef: wrapperRef,
		customClasses: 'gform-multi-option-button__popover',
		isHide: popupHide,
		isOpen: popupOpen,
		isReveal: popupReveal,
		placement: 'bottom',
		popoverRef: popupRef,
		triggerRef,
		...restPopoverAttributes,
		popoverAttributes: {
			...popoverCustomAttributes,
			'aria-labelledby': triggerId,
			id: popoverId,
			onKeyDown: ( event ) => {
				popoverCustomAttributes?.onKeyDown?.( event );
				handleEscKeyDown( event );
			},
		},
		popoverClasses: classnames( {
			'gform-multi-option-button__popover-inner': true,
		}, popoverClasses ),
	};

	/**
	 * @description Gets props for a dropdown option button.
	 *
	 * @since 6.6.2
	 *
	 * @param {object}  option        The option config.
	 * @param {object}  args          Additional arguments.
	 * @param {boolean} args.isSizing Whether the option is being rendered for sizing only.
	 *
	 * @return {object} The option button props.
	 */
	const getOptionButtonProps = ( option, { isSizing = false } = {} ) => {
		const {
			onClick: optionOnClick = () => {},
			customAttributes: optionCustomAttributes = {},
			customClasses: optionCustomClasses = [],
			...optionProps
		} = option;
		const optionDisabled = componentDisabled || optionProps.disabled;
		const optionAttributes = isSizing ? {
			'aria-hidden': 'true',
			role: 'presentation',
			tabIndex: -1,
			type: 'button',
			...optionCustomAttributes,
		} : {
			role: 'menuitem',
			type: 'button',
			...optionCustomAttributes,
		};

		return {
			...optionButtonDefaults,
			...optionProps,
			customAttributes: optionAttributes,
			customClasses: classnames( {
				'gform-multi-option-button__option': true,
				'gform-multi-option-button__sizer-option': isSizing,
			}, optionCustomClasses ),
			disabled: optionDisabled,
			onClick: async ( event ) => {
				if ( isSizing || optionDisabled ) {
					return;
				}

				try {
					await optionOnClick( event, option );
				} finally {
					if ( closeOnOptionClick ) {
						closePopup();
						triggerRef?.current?.focus();
					}
				}
			},
		};
	};

	/**
	 * @description Renders an option button.
	 *
	 * @since 6.6.2
	 *
	 * @param {object}  option   The option config.
	 * @param {object}  args     Additional arguments.
	 * @param {boolean} args.isSizing Whether the option is being rendered for sizing only.
	 *
	 * @return {JSX.Element} The option button.
	 */
	const renderOptionButton = ( option, { isSizing = false } = {} ) => (
		<Button
			{ ...getOptionButtonProps( option, { isSizing } ) }
			key={ `${ option.key || option.label }${ isSizing ? '-sizer' : '' }` }
		/>
	);

	return (
		<div { ...wrapperProps } ref={ handleWrapperRef }>
			<Button { ...mainButtonProps }>
				{ children }
			</Button>
			<Button { ...triggerButtonProps } ref={ triggerRef } />
			<div className="gform-multi-option-button__sizer" aria-hidden="true">
				{ options.map( ( option ) => renderOptionButton( option, { isSizing: true } ) ) }
			</div>
			<Popover { ...popoverProps }>
				<div className="gform-multi-option-button__options" role="menu">
					{ options.map( ( option ) => renderOptionButton( option ) ) }
				</div>
			</Popover>
		</div>
	);
} );

MultiOptionButton.propTypes = {
	active: PropTypes.bool,
	activeText: PropTypes.string,
	activeType: PropTypes.oneOf( [ 'loader' ] ),
	ariaLabel: PropTypes.string,
	children: PropTypes.oneOfType( [
		PropTypes.arrayOf( PropTypes.node ),
		PropTypes.node,
	] ),
	circular: PropTypes.bool,
	closeOnOptionClick: PropTypes.bool,
	customAttributes: PropTypes.object,
	customClasses: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.array,
		PropTypes.object,
	] ),
	disabled: PropTypes.bool,
	disableWhileActive: PropTypes.bool,
	dropdownAriaLabel: PropTypes.string,
	dropdownIcon: PropTypes.string,
	dropdownIconPrefix: PropTypes.string,
	icon: PropTypes.string,
	iconAttributes: PropTypes.object,
	iconPosition: PropTypes.oneOf( [ 'leading', 'trailing' ] ),
	iconPrefix: PropTypes.string,
	label: PropTypes.string,
	loaderProps: PropTypes.object,
	lockSize: PropTypes.bool,
	mainButtonAttributes: PropTypes.object,
	mainButtonClasses: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.array,
		PropTypes.object,
	] ),
	onClick: PropTypes.func,
	onDropdownClose: PropTypes.func,
	onDropdownOpen: PropTypes.func,
	options: PropTypes.arrayOf( PropTypes.object ),
	popoverAttributes: PropTypes.object,
	popoverClasses: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.array,
		PropTypes.object,
	] ),
	size: PropTypes.string,
	spacing: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.number,
		PropTypes.array,
		PropTypes.object,
	] ),
	triggerAttributes: PropTypes.object,
	triggerClasses: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.array,
		PropTypes.object,
	] ),
	type: PropTypes.string,
	width: PropTypes.string,
};

MultiOptionButton.displayName = 'Elements/Button/MultiOptionButton';

export default MultiOptionButton;