import React, { type ReactNode, type RefObject, useEffect, useRef } from 'react' import Sortable from 'sortablejs' import styles from './_draggable-list.module.scss' import { c } from '../../translations/LibraryTranslationService' export type DraggableListProps = { /** Callout that returns the newly re-ordered list items. */ callout: (newList: DataItem[]) => void /** Array of DOM elements that need drag and drop support. */ children: Array /** Array of options. */ listItems: DataItem[] /** Optional container selector. Use this if we are not directly wrapping around a list and instead we want to specify some other container nested deep inside. */ containerSelector?: RefObject /** Optional prop to disable default behavior of SortableJS. True by default as this fixes SortableJS buggy behavior. For some cases there won't be any issues and in such cases this can be set to false. */ overrideDOMManipulation?: boolean /** Custom class to style the wrapper */ wrapperClass?: string /** Optional prop to add a test id to the DraggableList for QA testing */ qaTestId?: string } const DraggableList = ({ callout, children, listItems, containerSelector, overrideDOMManipulation = true, wrapperClass, qaTestId = 'draggable-list', }: DraggableListProps) => { const draggableContainerRef = useRef(null) useEffect(() => { let sortable: Sortable | null = null const draggableContainer = containerSelector ? containerSelector.current : draggableContainerRef.current if (draggableContainer) { sortable = draggableContainer ? new Sortable(draggableContainer, { animation: 250, multiDrag: true, // If this class is changed, then changes in DragHandle.tsx must be made to match this. handle: '.drag-handle', // Selector for the handle dragClass: styles.dragging, scroll: true, direction: 'vertical', onMove: (evt) => { // Logic to add the drop location and its styling while we are dragging any item. evt.from.querySelector(`.${styles.ghostMask}`)?.remove() const element = evt.dragged element.classList.add(styles.draggingContainer) const ghostElement = document.createElement('div') ghostElement.textContent = c('dropHere') ghostElement.classList.add(styles.ghostMask) evt.dragged.insertBefore( ghostElement, evt.dragged.children[evt.dragged.children.length], ) }, onEnd: (evt) => { // Remove the drop placeholder and its styling once the item is dropped. evt.from.querySelector(`.${styles.ghostMask}`)?.remove() const { oldIndex, newIndex } = evt const newList = [...listItems] if ( oldIndex !== undefined && newIndex !== undefined && oldIndex !== newIndex // Manipulate only if the item is moved to a different position ) { // Logic to alter the DOM - Start if (overrideDOMManipulation) { evt.item.remove() evt.from.insertBefore(evt.item, evt.from.children[oldIndex]) } // - End - // Logic to alter the order of the selectedList const draggedOut = newList[oldIndex] // Insert a copy of the dragged out item to its new position oldIndex > newIndex ? newList.splice(newIndex, 0, draggedOut) : newList.splice(newIndex + 1, 0, draggedOut) // Remove the dragged out item from the old position oldIndex > newIndex ? newList.splice(oldIndex + 1, 1) : newList.splice(oldIndex, 1) callout(newList) } }, }) : null } return () => { sortable?.destroy() } }, [callout, containerSelector, listItems, overrideDOMManipulation]) return (
{children}
) } export default DraggableList