/**
* 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 }
);
}