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;