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