/** * WordPress dependencies */ import { useId, useRef, useState } from '@safe-wordpress/element'; /** * External dependencies */ import clsx from 'clsx'; import { isEqual } from 'lodash'; import { useDrag, useDrop, type XYCoord } from 'react-dnd'; /** * Internal dependencies */ import './style.scss'; export type SortableListProps< T extends { readonly id: string | number } > = { readonly className?: string; readonly disabled: boolean; readonly items: ReadonlyArray< T >; readonly render: ( props: { readonly item: NoInfer< T >; readonly index: number; } ) => JSX.Element; readonly onSort: ( items: ReadonlyArray< T[ 'id' ] > ) => void; }; export function SortableList< T extends { readonly id: string | number } >( { className, disabled, items, render: Item, onSort, }: SortableListProps< T > ): JSX.Element { const dndType = useId(); const [ draggingItems, setDraggingItems ] = useState< typeof items >( [] ); const onDragStart = () => { setDraggingItems( items ); }; const onDragEnd = () => { if ( ! isEqual( items, draggingItems ) && items.length === draggingItems.length ) { onSort( draggingItems.map( ( di ) => di.id ) ); } setDraggingItems( [] ); }; const onMove = ( dragIndex: number, hoverIndex: number ) => { if ( ! draggingItems[ dragIndex ] ) { return; } const aux = [ ...draggingItems ]; aux.splice( dragIndex, 1 ); aux.splice( hoverIndex, 0, draggingItems[ dragIndex ] ); setDraggingItems( aux ); }; const currentItems = draggingItems.length ? draggingItems : items; return (
{ currentItems.map( ( item, index ) => disabled ? ( ) : ( ) ) }
); } function ItemWrapper< T extends { readonly id: string | number } >( { dndType, children, item, index, onDragStart, onDragEnd, onMove, }: { readonly dndType: string; readonly children: JSX.Element; readonly item: T; readonly index: number; readonly onDragStart: () => void; readonly onDragEnd: () => void; readonly onMove: ( dragIndex: number, hoverIndex: number ) => void; } ): JSX.Element | null { const ref = useRef< HTMLDivElement >( null ); const [ _, dropReference ] = useDrop< { id: string | number; index: number; } >( { accept: dndType, hover: ( dragItem, monitor ) => { if ( ! ref.current ) { return; } const dragIndex = dragItem.index; const hoverIndex = index; // Don't replace items with themselves if ( dragIndex === hoverIndex ) { return; } // Determine rectangle on screen const hoverBoundingRect = ref.current?.getBoundingClientRect(); // Get vertical middle const hoverMiddleY = ( hoverBoundingRect.bottom - hoverBoundingRect.top ) / 2; // Determine mouse position const clientOffset = monitor.getClientOffset(); // Get pixels to the top const hoverClientY = ( clientOffset as XYCoord ).y - hoverBoundingRect.top; // Only perform the move when the mouse has crossed half of the items height // When dragging downwards, only move when the cursor is below 50% // When dragging upwards, only move when the cursor is above 50% // Dragging downwards if ( dragIndex < hoverIndex && hoverClientY < hoverMiddleY ) { return; } // Dragging upwards if ( dragIndex > hoverIndex && hoverClientY > hoverMiddleY ) { return; } // Time to actually perform the action onMove( dragIndex, hoverIndex ); // Note: we're mutating the monitor item here! // Generally it's better to avoid mutations, // but it's good here for the sake of performance // to avoid expensive index searches. dragItem.index = hoverIndex; }, } ); const [ { isDragging }, dragReference ] = useDrag( { type: dndType, item: () => { onDragStart(); return { id: item.id, index }; }, end: onDragEnd, collect: ( monitor ) => ( { isDragging: monitor.isDragging(), } ), } ); dropReference( dragReference( ref ) ); return (
{ children }
); }