modules_Droplist_index.js

import { React, classnames, PropTypes } from '@gravityforms/libraries';
import { IdProvider, useIdContext, useFocusTrap, usePopup } from '@gravityforms/react-utils';
import { focusSelector } from '@gravityforms/react-utils/src/hooks/helpers/tabbable';
import { spacerClasses } from '@gravityforms/utils';
import DroplistList from './DroplistList';
import { buildItemKey } from './utils';
import Button from '../../elements/Button';
import Popover from '../../elements/Popover';
import { ESCAPE } from '../../utils/keymap';

const { forwardRef, useCallback, useEffect, useState, useRef } = React;

const DroplistComponent = forwardRef( ( props, ref ) => { // eslint-disable-line no-unused-vars
	const {
		align,
		closeOnClick,
		customAttributes,
		customClasses,
		droplistAttributes,
		listItems,
		onAfterClose,
		onAfterOpen,
		onClose,
		onOpen,
		openOnHover,
		stackNestedGroups,
		triggerAttributes,
		width,
	} = props;
	const [ listItemsState, setListItemsState ] = useState( listItems );
	const [ depth, setDepth ] = useState( 0 );
	const [ selectedState, setSelectedState ] = useState( {} );
	const containerRef = useRef( null );
	const {
		closePopup,
		openPopup,
		handleEscKeyDown,
		popupHide,
		popupOpen,
		popupReveal,
		popupRef,
		triggerRef,
	} = usePopup( {
		onAfterClose: () => {
			onAfterClose();
			if ( ! stackNestedGroups ) {
				return;
			}
			setSelectedState( {} );
		},
		onAfterOpen,
		onClose: () => {
			onClose();
			if ( stackNestedGroups ) {
				return;
			}
			setSelectedState( {} );
		},
		onOpen,
	} );
	const trapRef = useFocusTrap( popupOpen );
	const id = useIdContext();

	const depthKeys = Object.keys( selectedState );

	/* Wrapper props */
	const wrapperProps = {
		className: classnames( {
			'gform-droplist': true,
		}, 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 = {
		customClasses: 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 ) => {
				triggerOnKeyDown( event );
				handleEscKeyDown( 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 popoverProps = {
		align,
		autoPlacement: true,
		containerRef,
		customClasses: classnames( {
			'gform-droplist__popover': true,
		}, droplistCustomClasses ),
		isHide: popupHide,
		isOpen: popupOpen,
		isReveal: popupReveal,
		popoverAttributes: {
			'aria-labelledby': triggerAriaId,
			onKeyDown: ( event ) => {
				droplistOnKeyDown( event );
				if ( stackNestedGroups && depth > 0 && event.key === ESCAPE ) {
					const filteredState = depthKeys
						.filter( ( key ) => key < depth - 1 )
						.reduce( ( acc, key ) => {
							acc[ key ] = selectedState[ key ];
							return acc;
						}, {} );
					setSelectedState( filteredState );
					return;
				}
				handleEscKeyDown( event );
			},
		},
		popoverClasses: spacerClasses( [ 2, 0, 0 ] ),
		popoverRef: popupRef,
		triggerRef,
		width,
		...restDroplistAttributes,
	};

	/* Droplist list props */
	const droplistListProps = {
		align,
		closeDroplist: closePopup,
		closeOnClick,
		depth,
		droplistId: id,
		itemKey: id,
		openOnHover,
		selectedState,
		setSelectedState,
		stackNestedGroups,
		listItems: listItemsState,
	};

	useEffect( () => {
		if ( ! stackNestedGroups ) {
			setDepth( 0 );
			setListItemsState( listItems );
			return;
		}

		if ( depthKeys.length === 0 ) {
			setDepth( 0 );
			setListItemsState( listItems );
		} else {
			const depthKeysArr = depthKeys.reduce( ( acc, key ) => {
				const parsedKey = parseInt( key, 10 );
				if ( ! isNaN( parsedKey ) && selectedState[ key ] ) {
					acc.push( parsedKey );
				}
				return acc;
			}, [] );
			if ( depthKeysArr.length === 0 ) {
				setDepth( 0 );
				setListItemsState( listItems );
			} else {
				const deepestKey = Math.max( ...depthKeysArr );
				const selectedId = selectedState[ deepestKey ];
				const findListItemsById = ( items, itemsDepth, idToFind ) => {
					for ( const [ itemIndex, item ] of items.entries() ) {
						if ( item?.triggerAttributes?.id && item.triggerAttributes.id === idToFind ) {
							return item.listItems || [];
						}
						const itemKey = buildItemKey( id, itemsDepth, itemIndex, 'group' );
						if ( `${ itemKey }-trigger` === idToFind ) {
							return item.listItems || [];
						}
						if ( item.listItems ) {
							const found = findListItemsById( item.listItems, itemsDepth + 1, idToFind );
							if ( found.length > 0 ) {
								return found;
							}
						}
					}
					return [];
				};
				const newListItems = findListItemsById( listItems, 0, selectedId );
				const newDepth = depthKeys.length ? Math.max( ...depthKeys.map( ( key ) => parseInt( key, 10 ) + 1 ) ) : 0;
				setListItemsState( newListItems );
				setDepth( newDepth );
			}
		}

		setTimeout( () => {
			if ( ! popupRef?.current || ! popupOpen ) {
				return;
			}

			const firstFocusableEl = popupRef.current.querySelector( focusSelector );
			if ( ! firstFocusableEl ) {
				return;
			}

			firstFocusableEl.focus();
		}, 0 );
	}, [ depthKeys, id, listItems, popupOpen, popupRef, selectedState, stackNestedGroups ] );

	const setRefs = useCallback( ( node ) => {
		trapRef( node );
		containerRef.current = node;
		if ( ref ) {
			ref.current = node;
		}
	}, [ ref, trapRef ] );

	return (
		<div { ...wrapperProps } ref={ setRefs }>
			{ triggerTitle ? null : (
				<span
					className="gform-visually-hidden"
					id={ triggerAriaId }
				>
					{ triggerAriaText }
				</span>
			) }
			<Button { ...triggerProps } />
			<Popover { ...popoverProps }>
				<DroplistList { ...droplistListProps } />
			</Popover>
		</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. Does not work when `stackNestedGroups` is true.
 * @param {boolean}             props.stackNestedGroups  Whether to stack nested groups.
 * @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,
		stackNestedGroups: 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' ] ),
	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,
	stackNestedGroups: PropTypes.bool,
	triggerAttributes: PropTypes.object,
	width: PropTypes.number,
};

Droplist.displayName = 'Droplist';

export default Droplist;