modules_SortableList_index.js

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;