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