@gravityforms/components
Version:
UI components for use in Gravity Forms development. Both React and vanilla js flavors.
459 lines (438 loc) • 16.4 kB
JavaScript
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;