modules_Repeater_index.js

import { React, PropTypes, classnames } from '@gravityforms/libraries';
import { spacerClasses } from '@gravityforms/utils';
import RepeaterItem from './RepeaterItem';
import { augmentItems } from './utils';
import Button from '../../elements/Button';
// import Box from '../../elements/Box';
import { getIdProvider, useId } from '../../utils/contexts/id-context';

const { forwardRef, useRef, useState, useCallback } = React;

/**
 * @module RepeaterComponent
 * @description A Repeater component to display a list of items that can be reordered with drag and drop or keyboard navigation.
 *
 * @since 4.7.0
 *
 * @param {object} props   Component props.
 * @param {object} refProp Ref to the component.
 *
 * @return {JSX.Element} The Repeater component.
 */
const RepeaterComponent = forwardRef( ( props, refProp ) => {
	const {
		addButtonAttributes,
		addButtonClasses,
		customAttributes,
		customClasses,
		deleteButtonAttributes,
		deleteButtonClasses,
		downButtonAttributes,
		downButtonClasses,
		dragHandleAttributes,
		dragHandleClasses,
		depth,
		i18n,
		isDraggable,
		isSortable,
		items,
		maxItems,
		// maxNesting, @todo: Implement when we need to render nested repeaters.
		minItems,
		newItemProps,
		NewItemTemplate,
		screenReaderAttributes,
		screenReaderClasses,
		showAdd,
		showArrows,
		showDragHandle,
		// showDropZone, @todo: Implement when we need to render drop zone.
		spacing,
		upButtonAttributes,
		upButtonClasses,
	} = props;
	const internalRef = useRef( null );
	const ref = refProp || internalRef;
	const id = useId();
	const idWithDepth = `${ id }-depth-${ depth }`;
	const [ repeaterItems, setRepeaterItems ] = useState( () => augmentItems( items, { id: idWithDepth } ) );
	const [ screenReaderText, setScreenReaderText ] = useState( null );

	// @todo: Implement when we need to listen for outside events. Also add ID to the event, in case of multiple repeaters.
	// useEffect( () => {
	// 	document.addEventListener( 'gform/repeater/add_item', addExternalItem );
	//
	// 	return () => {
	// 		document.removeEventListener( 'gform/repeater/add_item', addExternalItem );
	// 	};
	// } );

	/**
	 * @function moveItem
	 * @description Moves an item within a list by a given set of positions.
	 *
	 * @since 4.7.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 = repeaterItems[ originIndex ];
		const filtered = repeaterItems.filter( ( item, index ) => index !== originIndex );
		const inserted = [ ...filtered.slice( 0, newIndex ), moved, ...filtered.slice( newIndex ) ];
		setRepeaterItems( inserted );
	}, [ repeaterItems ] );

	/**
	 * @function renderRepeater
	 * @description Render a Repeater as a callback.
	 *
	 * @since 4.7.0
	 *
	 * @param {number}        origDepth The original depth of the item.
	 * @param {Array|boolean} children  The children of the item.
	 *
	 * @return void
	 */
	// @todo: Implement when we need to render nested repeaters.
	// const renderRepeater = ( origDepth, children ) => {
	// 	if ( maxNesting !== -1 && origDepth >= maxNesting ) {
	// 		return null;
	// 	}
	//
	// 	if ( ! Array.isArray( children ) ) {
	// 		return null;
	// 	}
	//
	// 	const newDepth = origDepth + 1;
	//
	// 	const repeaterAttrs = {
	// 		customAttributes,
	// 		customClasses,
	// 		items: children,
	// 		id,
	// 		depth: newDepth,
	// 		maxNesting,
	// 		showDragHandle,
	// 		showArrows,
	// 		showAdd,
	// 		showDropZone,
	// 		NewItemTemplate,
	// 		newItemProps,
	// 		spacing,
	// 	};
	//
	// 	return (
	// 		<Repeater { ...repeaterAttrs } />
	// 	);
	// };

	/**
	 * @function addItem
	 * @description Add an item to the list.
	 *
	 * @since 4.7.0
	 *
	 * @return void
	 */
	const addItem = () => {
		const newRepeaterItem = {
			id: `${ idWithDepth }-new-item-${ Date.now() }`,
			content: <NewItemTemplate { ...newItemProps } />,
			children: false,
		};

		const newList = [ ...repeaterItems, newRepeaterItem ];

		setRepeaterItems( newList );

		// @todo: Implement when we need to listen for outside events. Also add ID to the event, in case of multiple repeaters.
		// trigger( { event: 'gform/repeater/item_added', native: false, data: {
		// 	itemAdded: newRepeaterItem,
		// 	listItems: repeaterItems,
		// } } );
	};

	/**
	 * @function deleteItem
	 * @description Delete an item from the list.
	 *
	 * @since 4.7.0
	 *
	 * @param {string|number|object} arg The ID of the item to delete, or an event object with the itemID.
	 *
	 * @return void
	 */
	const deleteItem = ( arg ) => {
		let deleteId;

		if ( typeof arg === 'string' || typeof arg === 'number' ) {
			deleteId = arg;
		} else if ( arg && arg.detail && arg.detail.itemID ) {
			deleteId = arg.detail.itemID;
		}

		if ( ! deleteId ) {
			console.error( 'deleteItem was called without a valid ID or event.' );
			return;
		}

		setRepeaterItems( ( current ) => {
			return current.filter( ( item ) => deleteId !== item.id );
		} );

		// @todo: Implement when we need to listen for outside events. Also add ID to the event, in case of multiple repeaters.
		// trigger( { event: 'gform/repeater/item_deleted', native: false, data: {
		// 	deletedId: deleteId,
		// 	listItems: repeaterItems,
		// } } );
	};

	/**
	 * @function addExternalItem
	 * @description Add an item to the list from an external source.
	 *
	 * @since 4.7.0
	 *
	 * @param {Event} event The triggering event.
	 *
	 * @return void
	 */
	// @todo: Implement when we need to listen for outside events. Also add ID to the event, in case of multiple repeaters.
	// const addExternalItem = ( event ) => {
	// 	if ( ! ref.current ) {
	// 		return;
	// 	}
	//
	// 	let Component;
	//
	// 	const data = event?.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 : repeaterItems.length;
	//
	// 	const newRepeaterItem = {
	// 		id,
	// 		children,
	// 		content: <Component { ...props } />,
	// 	};
	//
	// 	const inserted = [ ...repeaterItems.slice( 0, insertIndex ), newRepeaterItem, ...repeaterItems.slice( insertIndex ) ];
	// 	setRepeaterItems( inserted );
	//
	// 	trigger( { event: 'gform/repeater/external_item_added', native: false, data: {
	// 		itemAdded: newRepeaterItem,
	// 		listItems: repeaterItems,
	// 	} } );
	// };

	/**
	 * @function renderListItem
	 * @description Render a list item.
	 *
	 * @since 4.7.0
	 *
	 * @param {object} listItem List item to render.
	 * @param {number} index    Index of list item.
	 *
	 * @return {JSX.Element|null}
	 */
	const renderListItem = ( listItem, index ) => {
		if ( ! listItem ) {
			return null;
		}

		const totalItems = repeaterItems.length;

		const repeaterItemAttrs = {
			children: listItem.children,
			content: listItem.content,
			deleteButtonAttributes,
			deleteButtonClasses,
			downButtonAttributes,
			downButtonClasses,
			dragHandleAttributes,
			dragHandleClasses,
			deleteItem,
			depth,
			i18n,
			id: listItem.id,
			isDraggable,
			isSortable,
			key: listItem.id,
			listIndex: index,
			minItems,
			moveItem,
			// renderRepeater,
			showArrows,
			showDragHandle,
			// showDropZone,
			speak: setScreenReaderText,
			totalItems,
			upButtonAttributes,
			upButtonClasses,
			...( listItem?.props || {} ),
		};

		if ( minItems && totalItems <= minItems ) {
			repeaterItemAttrs.deleteButtonAttributes = {
				...( listItem?.props?.deleteButtonAttributes || {} ),
				disabled: true,
			};
		}

		if ( totalItems === 1 ) {
			repeaterItemAttrs.downButtonAttributes = {
				...( listItem?.props?.downButtonAttributes || {} ),
				disabled: true,
			};
			repeaterItemAttrs.dragHandleAttributes = {
				...( listItem?.props?.dragHandleAttributes || {} ),
				disabled: true,
			};
			repeaterItemAttrs.upButtonAttributes = {
				...( listItem?.props?.upButtonAttributes || {} ),
				disabled: true,
			};
		}

		return <RepeaterItem { ...repeaterItemAttrs } />;
	};

	/**
	 * @function renderEmptyState
	 * @description Render the emptyh state UI.
	 *
	 * @since 4.7.0
	 *
	 * @return {JSX.Element} A JSX element.
	 */
	// @todo: Implement when empty state is needed.
	// const renderEmptyState = () => {
	// 	const boxAttrs = {
	// 		customClasses: [
	// 			'gform-repeater__empty-wrapper',
	// 		],
	// 		y: 172,
	// 		display: 'flex',
	// 	};
	//
	// 	const buttonArgs = {
	// 		type: 'unstyled',
	// 		label: 'Drag Fields Here',
	// 		customClasses: [
	// 			'gform-repeater__empty-add-button',
	// 		],
	// 		onClick: addItem,
	// 	};
	//
	// 	return (
	// 		<Box { ...boxAttrs }>
	// 			<Button { ...buttonArgs } />
	// 		</Box>
	// 	);
	// };

	const componentProps = {
		className: classnames( {
			'gform-repeater': true,
			...spacerClasses( spacing ),
		}, customClasses ),
		id: `${ idWithDepth }-list-wrapper`,
		...customAttributes,
	};

	const screenReaderProps = {
		id: `${ idWithDepth }-repeater-screen-reader-text`,
		className: classnames( [
			'gform-repeater__screen-reader-text',
			'gform-visually-hidden',
		], screenReaderClasses ),
		'aria-live': 'polite',
		...screenReaderAttributes,
	};

	const addButtonProps = {
		label: 'Add',
		type: 'white',
		iconPosition: 'leading',
		onClick: addItem,
		iconPrefix: 'gform-icon',
		icon: 'plus-regular',
		size: 'size-height-m',
		customClasses: classnames( [
			'gform-repeater__add-button',
		], addButtonClasses ),
		disabled: maxItems && repeaterItems.length >= maxItems,
		...addButtonAttributes,
	};

	return (
		<div { ...componentProps } ref={ ref } >
			<div { ...screenReaderProps } >
				{ screenReaderText }
			</div>
			{ /*{ ( repeaterItems.length === 0 && showDropZone ) && renderEmptyState() }*/ }
			{ repeaterItems.map( ( listItem, index ) => renderListItem( listItem, index ) ) }
			{ showAdd && <Button { ...addButtonProps } /> }
		</div>
	);
} );

/**
 * @module Repeater
 * @description A Repeater component with id wrapper.
 *
 * @since 4.7.0
 *
 * @param {object}                     props                            Component props.
 * @param {object}                     props.addButtonAttributes        Custom attributes for the add button.
 * @param {string|Array|object}        props.addButtonClasses           Custom classes for the add button.
 * @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 the repeater.
 * @param {object}                     props.i18n                       The i18n object for speak functions and a11y.
 * @param {object}                     props.i18n.beginDrag             The message to speak when beginning a drag event (used for a11y).
 * @param {object}                     props.i18n.deleteLabel           The label for the delete button (used for a11y).
 * @param {object}                     props.i18n.dragLabel             The label for the drag button (used for a11y).
 * @param {object}                     props.i18n.endDrag               The message to speak when ending a drag event (used for a11y).
 * @param {object}                     props.i18n.endDrop               The message to speak when an item is dropped (used for a11y).
 * @param {object}                     props.i18n.moveItem              The message to speak when moving an item (used for a11y).
 * @param {string}                     props.id                         The ID of the repeater.
 * @param {boolean}                    props.isDraggable                Whether the items in this list are draggable.
 * @param {boolean}                    props.isSortable                 Whether the items in this list are sortable.
 * @param {Array}                      props.items                      The items for the repeater.
 * @param {number}                     props.maxItems                   The max number of items to allow.
 * @param {number}                     props.maxNesting                 The max number of levels of nesting to allow.
 * @param {number}                     props.minItems                   The min number of items 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 {object}                     props.screenReaderTextAttributes Custom attributes for the screen reader.
 * @param {string|Array|object}        props.screenReaderClasses        Custom classes for the screen reader.
 * @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 {string|number|Array|object} props.spacing                    The spacing for the component, as a string, number, array, or object.
 * @param {string}                     ref                              The ref to the component.
 *
 * @return {JSX.Element} The Repeater component.
 *
 * @example
 * import Repeater from '@gravityforms/components/react/admin/modules/Repeater';
 *
 * return (
 *      <Repeater
 *          items={ [] }
 *          spacing={ { '': 6, md: 8 } }
 *      />
 * );
 *
 */
const Repeater = forwardRef( ( props, ref ) => {
	const defaultProps = {
		addButtonAttributes: {},
		addButtonClasses: [],
		customAttributes: {},
		customClasses: [],
		deleteButtonAttributes: {},
		deleteButtonClasses: [],
		depth: 0,
		downButtonAttributes: {},
		downButtonClasses: [],
		dragHandleAttributes: {},
		dragHandleClasses: [],
		i18n: {
			beginDrag: 'Entering drag and drop for item %1$s.',
			deleteLabel: 'Click to delete this item.',
			dragLabel: 'Click to toggle drag and drop.',
			endDrag: 'Exiting drag and drop for item %1$s.',
			endDrop: '%1$s moved to position %2$s.',
			moveItem: 'Moving item %1$s to %2$s',
		},
		id: '',
		isDraggable: false,
		isSortable: false,
		items: [],
		maxItems: 0,
		maxNesting: -1,
		minItems: 0,
		newItemProps: {},
		NewItemTemplate: '',
		screenReaderAttributes: {},
		screenReaderClasses: [],
		showAdd: true,
		showArrows: true,
		showDragHandle: true,
		showDropZone: false,
		spacing: '',
		upButtonAttributes: {},
		upButtonClasses: [],
	};
	const combinedProps = { ...defaultProps, ...props };
	const { id: idProp } = combinedProps;
	const IdProvider = getIdProvider( { id: idProp } );

	return (
		<IdProvider>
			<RepeaterComponent { ...combinedProps } ref={ ref } />
		</IdProvider>
	);
} );

Repeater.propTypes = {
	customAttributes: PropTypes.object,
	customClasses: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.array,
		PropTypes.object,
	] ),
	depth: PropTypes.number,
	items: PropTypes.array,
	maxNesting: PropTypes.number,
	NewItemTemplate: PropTypes.oneOfType( [
		PropTypes.element,
		PropTypes.func,
		PropTypes.string,
	] ),
	newItemProps: PropTypes.object,
	showDragHandle: PropTypes.bool,
	showArrows: PropTypes.bool,
	showAdd: PropTypes.bool,
	showDropZone: PropTypes.bool,
	spacing: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.number,
		PropTypes.array,
		PropTypes.object,
	] ),
};

Repeater.displayName = 'Repeater';

export default Repeater;