UNPKG

@gravityforms/components

Version:

UI components for use in Gravity Forms development. Both React and vanilla js flavors.

459 lines (438 loc) 16.4 kB
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;