modules_Dropdown_index.js

import { React, PropTypes, classnames } from '@gravityforms/libraries';
import { IdProvider, useIdContext, StoreProvider, useStoreContext } from '@gravityforms/react-utils';
import { spacerClasses } from '@gravityforms/utils';
import useDropdownControl from './hooks/control';
import useDropdownBlur from './hooks/blur';
import useDropdownKeyDown from './hooks/key-down';
import useDropdownTypeahead from './hooks/typeahead';
import DropdownLabel from './DropdownLabel';
import DropdownList from './DropdownList';
import DropdownPills from './DropdownPills';
import DropdownPopover from './DropdownPopover';
import DropdownSearch from './DropdownSearch';
import DropdownTrigger from './DropdownTrigger';
import {
	getListItemsState,
	filterListItems,
	convertSingleToMultiItem,
	convertMultiToSingleItem,
	getSelectedItemFromValue,
} from './utils';
import createStore from './store';

const { forwardRef, useEffect, useRef } = React;

/**
 * @module DropdownComponent
 * @description The dropdown component.
 *
 * @since 4.5.0
 *
 * @param {object} props The component props.
 * @param {object} ref   The ref object.
 *
 * @return {JSX.Element} The dropdown component.
 */
const DropdownComponent = forwardRef( ( props, ref ) => {
	const id = useIdContext();
	const listItems = useStoreContext( ( state ) => state.listItems );
	const dropdownOpen = useStoreContext( ( state ) => state.open );
	const dropdownReveal = useStoreContext( ( state ) => state.reveal );
	const dropdownHide = useStoreContext( ( state ) => state.hide );
	const searchValue = useStoreContext( ( state ) => state.searchValue );
	const selectedItem = useStoreContext( ( state ) => state.selectedItem );
	const initialTriggerHeight = useStoreContext( ( state ) => state.initialTriggerHeight );
	const setListItems = useStoreContext( ( state ) => state.setListItems );
	const setActiveItem = useStoreContext( ( state ) => state.setActiveItem );
	const setSelectedItem = useStoreContext( ( state ) => state.setSelectedItem );
	const setTriggerHeight = useStoreContext( ( state ) => state.setTriggerHeight );
	const setInitialTriggerHeight = useStoreContext( ( state ) => state.setInitialTriggerHeight );
	const setBaseElRef = useStoreContext( ( state ) => state.setBaseElRef );
	const listItemsMounted = useRef( false );

	const triggerRef = useStoreContext( ( state ) => state.triggerRef );
	const popoverRef = useStoreContext( ( state ) => state.popoverRef );
	const listRef = useStoreContext( ( state ) => state.listRef );
	const searchRef = useStoreContext( ( state ) => state.searchRef );
	const baseElRef = useStoreContext( ( state ) => state.baseElRef );
	const pillsRef = useStoreContext( ( state ) => state.pillsRef );

	const {
		ajaxSearch = false,
		controlled = false,
		customAttributes = {},
		customClasses = [],
		hasSearch = false,
		listItems: listItemsProp = [],
		multi = false,
		popoverPosition = 'bottom',
		simplebar = true,
		size = 'r',
		spacing = '',
		width = 0,
		value = '',
	} = props;

	/* Set active item and list items state when id, list items, or search value changes. */
	useEffect( () => {
		if ( ! listItemsMounted.current ) {
			listItemsMounted.current = true;
			return;
		}
		const filteredListItems = ajaxSearch ? listItemsProp : filterListItems( listItemsProp, searchValue );
		const newListItems = getListItemsState(
			filteredListItems,
			{ hasSearch, id },
		);
		setActiveItem( newListItems.flatItems[ 0 ] );
		setListItems( newListItems );
	}, [ hasSearch, id, listItemsProp, searchValue, setActiveItem, setListItems ] );
	/* Set selected item if controlled and value changes. */
	useEffect( () => {
		if ( controlled ) {
			setSelectedItem( getSelectedItemFromValue( value, listItems.flatItems, multi ) );
		}
	}, [ controlled, value, listItems.ids.join(), setSelectedItem, multi ] ); // eslint-disable-line react-hooks/exhaustive-deps
	/* Focus on base element when dropdown opens. */
	useEffect( () => {
		if ( ! dropdownOpen ) {
			return;
		}
		baseElRef?.current?.focus();
	}, [ dropdownOpen, baseElRef ] );
	/* Set the base element when hasSearch changes. */
	useEffect( () => {
		setBaseElRef( hasSearch ? searchRef : listRef );
	}, [ hasSearch, listRef, searchRef, setBaseElRef ] );
	/* Convert single to multi value and multi to single value when multi changes. */
	useEffect( () => {
		const newSelectedItem = multi
			? convertSingleToMultiItem( selectedItem )
			: convertMultiToSingleItem( selectedItem, listItems.flatItems );
		setSelectedItem( newSelectedItem );
	}, [ multi ] ); // eslint-disable-line react-hooks/exhaustive-deps
	/* Set initial trigger height. */
	useEffect( () => {
		if ( ! triggerRef.current ) {
			return;
		}
		/* Use of ResizeObserver accounts for more robust handling of trigger
		height, such as when used within a component which uses CSS
		transitions/animations to display. */
		const resizeObserver = new ResizeObserver( ( entries ) => {
			if ( ! triggerRef.current ) {
				return;
			}

			// eslint-disable-next-line no-unused-vars
			for ( const entry of entries ) {
				const triggerHeight = triggerRef.current.offsetHeight;
				if ( triggerHeight > 0 ) {
					if ( pillsRef.current && pillsRef.current.children.length > 0 ) {
						setInitialTriggerHeight( ( current ) => current || triggerHeight );
					} else {
						setInitialTriggerHeight( triggerHeight );
					}
				}
			}
		} );
		resizeObserver.observe( triggerRef.current );
		return () => resizeObserver.disconnect();
	}, [ triggerRef, pillsRef, setInitialTriggerHeight ] );
	/* Set trigger height when selected item changes in multi. */
	useEffect( () => {
		if ( ! multi ) {
			setTriggerHeight( 0 );
			return;
		}

		if ( ! pillsRef.current ) {
			return;
		}

		if ( ! initialTriggerHeight ) {
			return;
		}

		const pillsHeight = pillsRef.current.offsetHeight;
		if ( pillsHeight <= initialTriggerHeight ) {
			setTriggerHeight( 0 );
			return;
		}

		setTriggerHeight( pillsHeight );
	}, [ multi, selectedItem, pillsRef, initialTriggerHeight, setTriggerHeight ] );

	const dropdownProps = {
		className: classnames( {
			'gform-dropdown': true,
			'gform-dropdown--react': true,
			[ `gform-dropdown--popover-position-${ popoverPosition }` ]: true,
			[ `gform-dropdown--size-${ size }` ]: size,
			'gform-dropdown--open': dropdownOpen,
			'gform-dropdown--reveal': dropdownReveal,
			'gform-dropdown--hide': dropdownHide,
			'gform-dropdown--multi': multi,
			'gform-dropdown--has-simplebar': simplebar,
			'gform-dropdown--has-search': hasSearch,
			'gform-dropdown--ajax-search': ajaxSearch,
			...spacerClasses( spacing ),
		}, customClasses ),
		style: {
			width: width ? `${ width }px` : undefined,
		},
		...customAttributes,
	};

	return (
		<div { ...dropdownProps } ref={ ref }>
			<DropdownLabel { ...props } />
			<div className="gform-dropdown__trigger-wrapper">
				<DropdownTrigger { ...props } ref={ triggerRef } />
				<DropdownPills { ...props } ref={ pillsRef } />
			</div>
			<DropdownPopover { ...props } ref={ popoverRef }>
				<DropdownSearch { ...props } ref={ searchRef } />
				<DropdownList { ...props } ref={ listRef } />
			</DropdownPopover>
		</div>
	);
} );

const useDropdown = ( props ) => {
	const hooks = [
		useDropdownControl,
		useDropdownBlur,
		useDropdownKeyDown,
		useDropdownTypeahead,
	];

	return hooks.reduce( ( carryProps, hook ) => hook( carryProps, useStoreContext ), props );
};

const StoreProviderWrapper = ( {
	children,
	controlled,
	hasSearch,
	initialValue,
	listItems: listItemsProp,
	multi,
	value,
	i18n = {},
} ) => {
	const id = useIdContext();
	const triggerRef = useRef();
	const popoverRef = useRef();
	const listRef = useRef();
	const searchRef = useRef();
	const pillsRef = useRef();
	const listItems = getListItemsState( listItemsProp, { hasSearch, id } );
	const firstItem = listItems.flatItems[ 0 ] || {};
	let selectedItem = multi ? [] : firstItem;
	if ( controlled && value ) {
		selectedItem = getSelectedItemFromValue( value, listItems.flatItems, multi );
	} else if ( initialValue ) {
		selectedItem = getSelectedItemFromValue( initialValue, listItems.flatItems, multi );
	}
	const activeItem = firstItem;
	const storeProviderProps = {
		initialState: {
			listItems,
			selectedItem,
			activeItem,
			triggerRef,
			popoverRef,
			listRef,
			searchRef,
			baseElRef: hasSearch ? searchRef : listRef,
			pillsRef,
			i18n,
		},
		createStore,
	};
	return (
		<StoreProvider { ...storeProviderProps }>
			{ children }
		</StoreProvider>
	);
};

const DropdownWrapper = forwardRef( ( props, ref ) => {
	const componentProps = useDropdown( props );
	return <DropdownComponent { ...componentProps } ref={ ref } />;
} );

/**
 * @module Dropdown
 * @description Dropdown component with store and id wrapper.
 *
 * @since 4.5.0
 *
 * @param {object}                     props                    Component props.
 * @param {boolean}                    props.ajaxSearch         Whether to use ajax search for the dropdown.
 * @param {boolean}                    props.condensePills      Whether to condense pills in multi dropdown.
 * @param {boolean}                    props.controlled         Whether the dropdown is controlled or not.
 * @param {object}                     props.customAttributes   Custom attributes for the component.
 * @param {string|Array|object}        props.customClasses      Custom classes for the component.
 * @param {boolean}                    props.disabled           Whether the dropdown is disabled or not.
 * @param {boolean}                    props.hasSearch          Whether the dropdown has search or not.
 * @param {object}                     props.i18n               i18n strings.
 * @param {string}                     props.id                 The ID of the dropdown.
 * @param {string|number|Array|object} props.initialValue       Initial value for the dropdown.
 * @param {string}                     props.label              The label text.
 * @param {object}                     props.labelAttributes    Custom attributes for the label.
 * @param {string|Array|object}        props.labelClasses       Custom classes for the label.
 * @param {object}                     props.listAttributes     Custom attributes for the list.
 * @param {string|Array|object}        props.listClasses        Custom classes for the list.
 * @param {Array}                      props.listItems          The list items for the dropdown.
 * @param {boolean}                    props.multi              Whether the dropdown is a multi dropdown or not.
 * @param {Function}                   props.onAfterClose       Callback for after the dropdown closes.
 * @param {Function}                   props.onAfterOpen        Callback for after the dropdown opens.
 * @param {Function}                   props.onChange           Callback for when the dropdown changes.
 * @param {Function}                   props.onClose            Callback for when the dropdown closes.
 * @param {Function}                   props.onOpen             Callback for when the dropdown opens.
 * @param {Function}                   props.onSearch           Callback for when the search value changes.
 * @param {object}                     props.popoverAttributes  Custom attributes for the popover.
 * @param {string|Array|object}        props.popoverClasses     Custom classes for the popover.
 * @param {number}                     props.popoverMaxHeight   The maximum height of the popover.
 * @param {string}                     props.popoverPosition    The position of the popover.
 * @param {object}                     props.searchAttributes   Custom attributes for the search.
 * @param {string|Array|object}        props.searchClasses      Custom classes for the search.
 * @param {boolean}                    props.searchIsLoading    Whether the dropdown list items are loading from search or not.
 * @param {string}                     props.selectedIcon       The icon for the selected state in multi dropdown.
 * @param {string}                     props.selectedIconPrefix The prefix for the icon library to be used in multi dropdown.
 * @param {boolean}                    props.simplebar          Whether to use simplebar for the dropdown.
 * @param {string}                     props.size               The size of the dropdown, one of `r`, `l`, `xl`.
 * @param {string|number|Array|object} props.spacing            The spacing for the component, as a string, number, array, or object.
 * @param {object}                     props.triggerAttributes  Custom attributes for the trigger.
 * @param {string|Array|object}        props.triggerClasses     Custom classes for the trigger.
 * @param {string|number|Array|object} props.value              The value of the dropdown. Only works in controlled mode.
 * @param {number}                     props.width              The width of the dropdown.
 * @param {Function}                   ref                      The ref to the dropdown component.
 *
 * @return {JSX.Element} The dropdown component.
 */
const Dropdown = forwardRef( ( props, ref ) => {
	const defaultProps = {
		ajaxSearch: false,
		condensePills: false,
		controlled: false,
		customAttributes: {},
		customClasses: [],
		disabled: false,
		hasSearch: false,
		i18n: {},
		id: '',
		initialValue: '',
		label: '',
		labelAttributes: {},
		labelClasses: [],
		listAttributes: {},
		listClasses: [],
		listItems: [],
		multi: false,
		onAfterClose: () => {},
		onAfterOpen: () => {},
		onChange: () => {},
		onClose: () => {},
		onOpen: () => {},
		onSearch: () => {},
		popoverAttributes: {},
		popoverClasses: [],
		popoverMaxHeight: 0,
		popoverPosition: 'bottom',
		required: false,
		requiredLabel: {
			size: 'text-sm',
			weight: 'medium',
		},
		searchAttributes: {},
		searchClasses: [],
		searchIsLoading: false,
		selectedIcon: 'check-mark-alt',
		selectedIconPrefix: 'gravity-component-icon',
		simplebar: true,
		size: 'r',
		spacing: '',
		triggerAttributes: {},
		triggerClasses: [],
		value: '',
		width: 0,
	};
	const combinedProps = { ...defaultProps, ...props };
	const { id: idProp } = combinedProps;
	const idProviderProps = { id: idProp };

	return (
		<IdProvider { ...idProviderProps }>
			<StoreProviderWrapper { ...combinedProps }>
				<DropdownWrapper { ...combinedProps } ref={ ref } />
			</StoreProviderWrapper>
		</IdProvider>
	);
} );

Dropdown.propTypes = {
	ajaxSearch: PropTypes.bool,
	condensePills: PropTypes.bool,
	controlled: PropTypes.bool,
	customAttributes: PropTypes.object,
	customClasses: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.array,
		PropTypes.object,
	] ),
	disabled: PropTypes.bool,
	hasSearch: PropTypes.bool,
	i18n: PropTypes.object,
	id: PropTypes.string,
	initialValue: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.number,
		PropTypes.array,
		PropTypes.object,
	] ),
	label: PropTypes.string,
	labelAttributes: PropTypes.object,
	labelClasses: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.array,
		PropTypes.object,
	] ),
	listAttributes: PropTypes.object,
	listClasses: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.array,
		PropTypes.object,
	] ),
	listItems: PropTypes.array,
	multi: PropTypes.bool,
	onAfterClose: PropTypes.func,
	onAfterOpen: PropTypes.func,
	onChange: PropTypes.func,
	onClose: PropTypes.func,
	onOpen: PropTypes.func,
	onSearch: PropTypes.func,
	popoverAttributes: PropTypes.object,
	popoverClasses: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.array,
		PropTypes.object,
	] ),
	popoverMaxHeight: PropTypes.number,
	popoverPosition: PropTypes.oneOf( [ 'top', 'bottom' ] ),
	required: PropTypes.bool,
	requiredLabel: PropTypes.object,
	searchAttributes: PropTypes.object,
	searchClasses: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.array,
		PropTypes.object,
	] ),
	searchIsLoading: PropTypes.bool,
	selectedIcon: PropTypes.string,
	selectedIconPrefix: PropTypes.string,
	simplebar: PropTypes.bool,
	size: PropTypes.oneOf( [ 'r', 'l', 'xl' ] ),
	spacing: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.number,
		PropTypes.array,
		PropTypes.object,
	] ),
	triggerAttributes: PropTypes.object,
	triggerClasses: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.array,
		PropTypes.object,
	] ),
	value: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.number,
		PropTypes.array,
		PropTypes.object,
	] ),
	width: PropTypes.number,
};

export default Dropdown;