UNPKG

@gravityforms/components

Version:

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

341 lines (321 loc) 9.81 kB
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;