UNPKG

@gravityforms/components

Version:

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

749 lines (663 loc) 21.5 kB
import { React, PropTypes, classnames } from '@gravityforms/libraries'; import { spacerClasses, trigger } from '@gravityforms/utils'; import SortableItem from './SortableItem'; import Button from '../../elements/Button'; import Box from '../../elements/Box'; const { useRef, useState, useCallback, useEffect } = React; /** * @module SortableList * @description A SortableList component to display a list of items that can be reordered with drag and drop or keyboard navigation. * * @since 3.3.6 * * @param {object} props Component props. * @param {string} props.border The border type. * @param {object} props.customAttributes Custom attributes for the component. * @param {string|Array|object} props.customClasses Custom classes for the component. * @param {number} props.depth The current depth of this list. * @param {Array} props.items The items for this list. * @param {number} props.maxNesting The max number of levels of nesting to allow. * @param {object} props.newItemProps The props to use for new items. * @param {string | Function | element} props.NewItemTemplate The template to use for new items. * @param {boolean} props.showAdd Whether to show the add button. * @param {boolean} props.showArrows Whether to show the navigation arrows. * @param {boolean} props.showDragHandle Whether to show the drag handle. * @param {boolean} props.showDropZone Whether to show the drop zone. * @param props.formFieldId * @param {string|number|Array|object} props.spacing The spacing for the component, as a string, number, array, or object. * @param {boolean} props.enableExternalDrag Whether to enable drag and drop from external sources. * @param {boolean} props.showDropIndicators Whether to show visual drop indicators during external drag operations. * @param {object} props.externalDragConfig Configuration for external drag behavior including selectors and data attributes. * * @return {JSX.Element} The Sortable List component. */ const SortableList = ( { customAttributes = {}, customClasses = [], spacing = '', items = [], formFieldId = 0, depth = 0, maxNesting = -1, showDragHandle = true, showArrows = true, showAdd = true, NewItemTemplate = '', newItemProps = {}, showDropZone = false, border = 'dashed', enableExternalDrag = false, showDropIndicators = false, externalDragConfig = { dragSourceSelectors: [], targetSelector: '', dataAttributes: { isOver: 'data-is-over-target', insertIndex: 'data-insert-index', }, }, } ) => { const ref = useRef( null ); const [ sortableItems, setSortableItems ] = useState( items ); const [ screenReaderText, setScreenReaderText ] = useState( null ); const [ dragOverIndex, setDragOverIndex ] = useState( null ); const [ isReceivingExternalDrag, setIsReceivingExternalDrag ] = useState( false ); const [ isGloballyDragging, setIsGloballyDragging ] = useState( false ); const [ newlyAddedId, setNewlyAddedId ] = useState( null ); const insertionIndexRef = useRef( null ); useEffect( () => { document.addEventListener( 'gform/sortable_list/add_item', addExternalItem ); // Only add external drag detection if enabled if ( enableExternalDrag ) { const handleExternalDragStart = ( e ) => { if ( ! isValidDragSource( e.target ) ) { return; } setIsGloballyDragging( true ); }; const handleExternalDragEnd = () => { setIsGloballyDragging( false ); setIsReceivingExternalDrag( false ); setDragOverIndex( null ); insertionIndexRef.current = null; clearAllTargetStates(); clearExternalDragState(); }; // Listen for drag events document.addEventListener( 'mousedown', handleExternalDragStart ); document.addEventListener( 'mouseup', handleExternalDragEnd ); // Listen for mouse movement when dragging from external sources document.addEventListener( 'mousemove', handleExternalDragMove ); return () => { document.removeEventListener( 'gform/sortable_list/add_item', addExternalItem ); document.removeEventListener( 'mousedown', handleExternalDragStart ); document.removeEventListener( 'mouseup', handleExternalDragEnd ); document.removeEventListener( 'mousemove', handleExternalDragMove ); }; } return () => { document.removeEventListener( 'gform/sortable_list/add_item', addExternalItem ); }; } ); /** * @function isValidDragSource * @description Check if the target element is a valid drag source based on configuration. * * @since 1.0.0 * * @param {Element} target The target element to check. * * @return {boolean} Whether the element is a valid drag source. */ const isValidDragSource = useCallback( ( target ) => { if ( ! externalDragConfig.dragSourceSelectors || externalDragConfig.dragSourceSelectors.length === 0 ) { return false; } return externalDragConfig.dragSourceSelectors.some( ( selector ) => target.closest( selector ) ); }, [ externalDragConfig.dragSourceSelectors ] ); /** * @function clearAllTargetStates * @description Clear data attributes from all target elements to prevent conflicts between multiple lists. * * @since 1.0.0 * * @return void */ const clearAllTargetStates = useCallback( () => { if ( ! externalDragConfig.targetSelector ) { return; } const allTargets = document.querySelectorAll( externalDragConfig.targetSelector ); allTargets.forEach( ( target ) => { if ( target.hasAttribute( externalDragConfig.dataAttributes.isOver ) ) { target.setAttribute( externalDragConfig.dataAttributes.isOver, 'false' ); } if ( target.hasAttribute( externalDragConfig.dataAttributes.insertIndex ) ) { target.removeAttribute( externalDragConfig.dataAttributes.insertIndex ); } } ); }, [ externalDragConfig ] ); /** * @function calculateDropIndex * @description Calculate the insertion index based on mouse position relative to existing items. * * @since 1.0.0 * * @param {number} mouseY The Y position of the mouse relative to the document. * * @return {number} The calculated insertion index. */ const calculateDropIndex = useCallback( ( mouseY ) => { if ( ! ref.current ) { return sortableItems.length; } const siblings = Array.from( ref.current.querySelectorAll( '.gform-sortable-list-item' ) ); for ( let idx = 0; idx < siblings.length; idx++ ) { const el = siblings[ idx ]; const rect = el.getBoundingClientRect(); const thisTop = rect.top + document.documentElement.scrollTop; const thisMiddle = thisTop + ( rect.height / 2 ); // For first item, use middle point for more intuitive insertion if ( idx === 0 && mouseY < thisMiddle ) { return 0; } // For all items, if mouse is above top, insert before this item if ( mouseY < thisTop ) { return idx; } } // If we reach here, insert at the end return siblings.length; }, [ sortableItems.length ] ); /** * @function isMouseOverThisList * @description Check if the mouse coordinates are over this specific list container. * * @since 1.0.0 * * @param {number} clientX The X coordinate of the mouse. * @param {number} clientY The Y coordinate of the mouse. * * @return {boolean} Whether the mouse is over this list. */ const isMouseOverThisList = useCallback( ( clientX, clientY ) => { if ( ! ref.current ) { return false; } const rect = ref.current.getBoundingClientRect(); const isOverThisListBounds = clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom; if ( ! isOverThisListBounds ) { return false; } // Check if mouse is over any child SortableList const childLists = ref.current.querySelectorAll( '.gform-sortable-list' ); for ( const childList of childLists ) { // Skip if it's the current list itself if ( childList === ref.current ) { continue; } const childRect = childList.getBoundingClientRect(); const isOverChild = clientX >= childRect.left && clientX <= childRect.right && clientY >= childRect.top && clientY <= childRect.bottom; if ( isOverChild ) { // Mouse is over a child list, so it's not over this parent list return false; } } return true; }, [] ); /** * @function updateExternalDragState * @description Update the visual state and data attributes when receiving external drag. * * @since 1.0.0 * * @param {number} insertIndex The calculated insertion index. * * @return void */ const updateExternalDragState = useCallback( ( insertIndex ) => { if ( ! isReceivingExternalDrag ) { setIsReceivingExternalDrag( true ); } if ( ref.current && externalDragConfig.dataAttributes ) { ref.current.setAttribute( externalDragConfig.dataAttributes.isOver, 'true' ); ref.current.setAttribute( externalDragConfig.dataAttributes.insertIndex, insertIndex ); } // Only show drop indicators if enabled if ( showDropIndicators ) { setDragOverIndex( insertIndex ); } insertionIndexRef.current = insertIndex; }, [ isReceivingExternalDrag, showDropIndicators, externalDragConfig ] ); /** * @function clearExternalDragState * @description Clear the visual state when no longer receiving external drag. * * @since 1.0.0 * * @return void */ const clearExternalDragState = useCallback( () => { setIsReceivingExternalDrag( false ); setDragOverIndex( null ); if ( ref.current && externalDragConfig.dataAttributes ) { ref.current.setAttribute( externalDragConfig.dataAttributes.isOver, 'false' ); // Remove the insert index attribute to prevent interference if ( ref.current.hasAttribute( externalDragConfig.dataAttributes.insertIndex ) ) { ref.current.removeAttribute( externalDragConfig.dataAttributes.insertIndex ); } } insertionIndexRef.current = null; }, [ externalDragConfig ] ); const handleExternalDragMove = useCallback( ( e ) => { // Only process if external drag is enabled and we know something is being dragged globally if ( ! enableExternalDrag || ! isGloballyDragging || ! ref.current ) { return; } const isOverList = isMouseOverThisList( e.clientX, e.clientY ); if ( ! isOverList ) { if ( isReceivingExternalDrag ) { clearExternalDragState(); } return; } clearAllTargetStates(); const insertIndex = calculateDropIndex( e.clientY + document.documentElement.scrollTop ); updateExternalDragState( insertIndex ); }, [ enableExternalDrag, isGloballyDragging, isReceivingExternalDrag, isMouseOverThisList, calculateDropIndex, clearAllTargetStates, updateExternalDragState, clearExternalDragState ] ); /** * @function moveItem * @description Moves an item within a list by a given set of positions. * * @since 1.0.0 * * @param {number} originIndex The original index of the item. * @param {number} newIndex The new index of the item. * * @return void */ const moveItem = useCallback( ( originIndex, newIndex ) => { const moved = sortableItems[ originIndex ]; const filtered = sortableItems.filter( ( item, idx ) => idx !== originIndex ); const inserted = [ ...filtered.slice( 0, newIndex ), moved, ...filtered.slice( newIndex ) ]; setSortableItems( inserted ); trigger( { event: 'gform/sortable_list/item_moved', native: false, data: { itemMoved: moved, listItems: inserted, formFieldId, } } ); }, [ sortableItems ] ); /** * @function renderSortableList * @description Render a nested SortableList component. * * @since 1.0.0 * * @param {number} origDepth The original depth of the item. * @param {Array | boolean} children The children of the item. * * @return {JSX.Element|null} The nested SortableList component or null. */ const renderSortableList = ( origDepth, children ) => { if ( maxNesting !== -1 && origDepth >= maxNesting ) { return null; } if ( children === false ) { return null; } const newDepth = origDepth + 1; const sortableAttrs = { customAttributes, customClasses, spacing, items: children, depth: newDepth, maxNesting, showDragHandle, showArrows, showAdd, showDropZone, NewItemTemplate, newItemProps, }; return ( <SortableList { ...sortableAttrs } /> ); }; /** * @function addItem * @description Add an item to the list. * * @since 1.0.0 * * @return void */ const addItem = () => { const newSortableItem = { id: `new-item-${ Date.now() }`, body: <NewItemTemplate { ...newItemProps } />, children: false, }; const newList = [ ...sortableItems, newSortableItem ]; setSortableItems( newList ); trigger( { event: 'gform/sortable_list/item_added', native: false, data: { itemAdded: newSortableItem, listItems: sortableItems, formFieldId, } } ); }; /** * @function deleteItem * @description Delete an item from the list. * * @since 1.0.0 * * @param {string} deleteId ID of the item to be deleted. * * @return void */ const deleteItem = ( deleteId ) => { const filteredItems = sortableItems.filter( ( item ) => deleteId !== item.id ); setSortableItems( filteredItems ); trigger( { event: 'gform/sortable_list/item_deleted', native: false, data: { deletedId: deleteId, listItems: filteredItems, formFieldId, } } ); }; /** * @function addExternalItem * @description Add an item to the list from an external source. * * @since 1.0.0 * * @param {Event} e The triggering event with item data in detail property. * * @return void */ const addExternalItem = ( e ) => { if ( ! ref.current ) { return; } let Component; const data = e?.detail || {}; const parent = data?.parent || null; const props = data?.props || newItemProps; const id = data?.id || `new-item-${ Date.now() }`; const children = data?.children || false; const componentType = data?.component || 'default'; if ( parent !== ref.current ) { return; } if ( componentType === 'default' ) { Component = NewItemTemplate; } else { Component = data.component; } props.deleteItem = deleteItem; props.id = id; const insertIndex = data.insertIndex !== false ? data.insertIndex : sortableItems.length; const newSortableItem = { id, children, body: <Component { ...props } />, }; const inserted = [ ...sortableItems.slice( 0, insertIndex ), newSortableItem, ...sortableItems.slice( insertIndex ) ]; setSortableItems( inserted ); setNewlyAddedId( id ); // Remove the 'new' status after the animation completes setTimeout( () => { setNewlyAddedId( null ); }, 100 ); }; /** * @function renderListItem * @description Render a list item with all necessary props. * * @since 1.0.0 * * @param {object} listItem The list item to render. * @param {number} index The index to render at. * * @return {JSX.Element|null} The rendered list item component. */ const renderListItem = ( listItem, index ) => { if ( ! listItem ) { return null; } let contents; // Handle function-based bodies as in Storybook mock data if ( typeof listItem.body === 'function' ) { contents = listItem.body( { deleteItem, id: listItem.id } ); } else { contents = React.cloneElement( listItem.body, { ...listItem.body.props, deleteItem, } ); } const sortableItemAttrs = { totalItems: sortableItems.length, depth, children: listItem.children, listIndex: index, key: listItem.id, contents, id: listItem.id, moveItem, speak: setScreenReaderText, renderSortableList, showDragHandle, showArrows, showDropZone, deleteItem, isNew: listItem.id === newlyAddedId, }; return ( <SortableItem { ...sortableItemAttrs } /> ); }; /** * @function renderDropIndicator * @description Render a visual indicator showing where an external item will be dropped. * * @since 1.0.0 * * @param {number} index The index where the indicator should appear. * @param {string} position Additional identifier for unique keys (e.g., 'start', 'end', 'empty'). * * @return {JSX.Element} The drop indicator component. */ const renderDropIndicator = ( index, position = '' ) => { const key = position ? `drop-indicator-${ position }-${ index }` : `drop-indicator-${ index }`; const isActive = isReceivingExternalDrag && dragOverIndex === index; return ( <div key={ key } className={ `gform-sortable-list-drop-indicator ${ isActive ? 'is-active' : '' }` } /> ); }; const dropIndicatorStyles = ` .gform-sortable-list-drop-indicator { background: none; border: 2px dashed #a7a7a7; border-radius: 4px; margin: 8px 0; height: 60px; max-height: 0; opacity: 0; overflow: hidden; pointer-events: none; width: 100%; } .gform-sortable-list-drop-indicator.is-active { max-height: 60px; opacity: 1; } .gform-sortable-list-item-wrapper.is-new { animation: gform-sortable-list-item-enter 100ms ease-out; overflow: hidden; } @keyframes gform-sortable-list-item-enter { from { max-height: 60px; opacity: 0; } to { max-height: 200px; opacity: 1; } } `; /** * @function renderEmptyState * @description Render the emptyh state UI. * * @since 1.0.0 * * @return {JSX.Element} A JSX element. */ const renderEmptyState = () => { const boxAttrs = { customClasses: [ 'gform-sortable-list-empty-wrapper', ], y: 172, display: 'flex', }; const buttonArgs = { type: 'unstyled', size: 'large', label: 'Drag Fields Here', customClasses: [ 'gform-sortable-list-empty-add-button', ], onClick: addItem, }; return ( <Box { ...boxAttrs }> <Button { ...buttonArgs } /> </Box> ); }; const componentAttrs = { className: classnames( { 'gform-sortable-list': true, [ `gform-sortable-list--${ border }` ]: true, 'gform-sortable-list__populated': sortableItems.length, ...spacerClasses( spacing ), }, customClasses ), id: `list-wrapper`, // Data attributes used by layout_editor.js for drop targeting ...( formFieldId && { 'data-repeater-field-id': formFieldId } ), style: { minHeight: sortableItems.length === 0 ? '180px' : 'auto', ...customAttributes.style, }, ...customAttributes, }; const srAttrs = { id: 'sortable-list-sr-text', className: 'gform-visually-hidden', 'aria-live': 'polite', }; const addAttrs = { label: 'Add', type: 'white', iconPosition: 'leading', onClick: addItem, iconPrefix: 'gform-icon', icon: 'plus-regular', }; if ( ! showDropZone && ! sortableItems.length ) { return null; } /** * @function renderItemsWithIndicators * @description Render all items with drop indicators interspersed between them. * * @since 1.0.0 * * @return {Array} Array of JSX elements including items and indicators. */ const renderItemsWithIndicators = () => { const itemsToRender = []; // Handle empty list case if ( sortableItems.length === 0 ) { if ( showDropIndicators && isReceivingExternalDrag ) { return ( <div key="drop-indicator-empty" className="gform-sortable-list-drop-indicator is-active" /> ); } return itemsToRender; } // Render indicator at the beginning itemsToRender.push( renderDropIndicator( 0, 'start' ) ); sortableItems.forEach( ( listItem, index ) => { itemsToRender.push( renderListItem( listItem, index ) ); // Render indicator after each item itemsToRender.push( renderDropIndicator( index + 1, 'after' ) ); } ); return itemsToRender; }; return ( <div { ...componentAttrs } ref={ ref } > <style>{ dropIndicatorStyles }</style> <div { ...srAttrs } > { screenReaderText } </div> { ( ! sortableItems.length > 0 && showDropZone && ! isReceivingExternalDrag ) && renderEmptyState() } { renderItemsWithIndicators() } { ( ( sortableItems.length > 0 || ! showDropZone ) && showAdd ) && <Button { ...addAttrs } /> } </div> ); }; SortableList.propTypes = { customAttributes: PropTypes.object, customClasses: PropTypes.oneOfType( [ PropTypes.string, PropTypes.array, PropTypes.object, ] ), spacing: PropTypes.oneOfType( [ PropTypes.string, PropTypes.number, PropTypes.array, PropTypes.object, ] ), items: PropTypes.array, formFieldId: PropTypes.number, depth: PropTypes.number, maxNesting: PropTypes.number, showDragHandle: PropTypes.bool, showArrows: PropTypes.bool, showAdd: PropTypes.bool, NewItemTemplate: PropTypes.oneOfType( [ PropTypes.element, PropTypes.func, PropTypes.string, ] ), newItemProps: PropTypes.object, showDropZone: PropTypes.bool, border: PropTypes.string, enableExternalDrag: PropTypes.bool, showDropIndicators: PropTypes.bool, externalDragConfig: PropTypes.shape( { dragSourceSelectors: PropTypes.arrayOf( PropTypes.string ).isRequired, targetSelector: PropTypes.string.isRequired, dataAttributes: PropTypes.shape( { isOver: PropTypes.string.isRequired, insertIndex: PropTypes.string.isRequired, } ).isRequired, } ), }; SortableList.displayName = 'SortableList'; export default SortableList;