import type { DndContextProps, DraggableSyntheticListeners, DropAnimation, UniqueIdentifier, } from '@dnd-kit/core' import { closestCenter, defaultDropAnimationSideEffects, DndContext, DragOverlay, KeyboardSensor, MouseSensor, TouchSensor, useSensor, useSensors, } from '@dnd-kit/core' import { restrictToHorizontalAxis, restrictToParentElement, restrictToVerticalAxis, } from '@dnd-kit/modifiers' import { arrayMove, horizontalListSortingStrategy, SortableContext, useSortable, verticalListSortingStrategy, type SortableContextProps, } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { Slot } from 'radix-ui' import { createContext, forwardRef, useContext, useMemo, useState } from 'react' import { createPortal } from 'react-dom' import { Button, cn, type ButtonProps } from 'ui' import { composeRefs } from '../hooks/useComposedRefs' const orientationConfig = { vertical: { modifiers: [restrictToVerticalAxis, restrictToParentElement], strategy: verticalListSortingStrategy, }, horizontal: { modifiers: [restrictToHorizontalAxis, restrictToParentElement], strategy: horizontalListSortingStrategy, }, mixed: { modifiers: [restrictToParentElement], strategy: undefined, }, } interface SortableProps extends DndContextProps { /** * An array of data items that the sortable component will render. * @example * value={[ * { id: 1, name: 'Item 1' }, * { id: 2, name: 'Item 2' }, * ]} */ value: TData[] /** * An optional callback function that is called when the order of the data items changes. * It receives the new array of items as its argument. * @example * onValueChange={(items) => console.log(items)} */ onValueChange?: (items: TData[]) => void /** * An optional callback function that is called when an item is moved. * It receives an event object with `activeIndex` and `overIndex` properties, representing the original and new positions of the moved item. * This will override the default behavior of updating the order of the data items. * @type (event: { activeIndex: number; overIndex: number }) => void * @example * onMove={(event) => console.log(`Item moved from index ${event.activeIndex} to index ${event.overIndex}`)} */ onMove?: (event: { activeIndex: number; overIndex: number }) => void /** * A collision detection strategy that will be used to determine the closest sortable item. * @default closestCenter * @type DndContextProps["collisionDetection"] */ collisionDetection?: DndContextProps['collisionDetection'] /** * An array of modifiers that will be used to modify the behavior of the sortable component. * @default * [restrictToVerticalAxis, restrictToParentElement] * @type Modifier[] */ modifiers?: DndContextProps['modifiers'] /** * A sorting strategy that will be used to determine the new order of the data items. * @default verticalListSortingStrategy * @type SortableContextProps["strategy"] */ strategy?: SortableContextProps['strategy'] /** * Specifies the axis for the drag-and-drop operation. It can be "vertical", "horizontal", or "both". * @default "vertical" * @type "vertical" | "horizontal" | "mixed" */ orientation?: 'vertical' | 'horizontal' | 'mixed' /** * An optional React node that is rendered on top of the sortable component. * It can be used to display additional information or controls. * @default null * @type React.ReactNode | null * @example * overlay={} */ overlay?: React.ReactNode | null } export function Sortable({ value, onValueChange, onDragStart, onDragEnd, onDragCancel, collisionDetection = closestCenter, modifiers, strategy, onMove, orientation = 'vertical', overlay, children, ...props }: SortableProps) { const [activeId, setActiveId] = useState(null) const sensors = useSensors( useSensor(MouseSensor), useSensor(TouchSensor), useSensor(KeyboardSensor) ) const config = orientationConfig[orientation] return ( { setActiveId(event.active.id) onDragStart?.(event) }} onDragEnd={(event) => { const { active, over } = event if (over && active.id !== over?.id) { const activeIndex = value.findIndex((item) => item.id === active.id) const overIndex = value.findIndex((item) => item.id === over.id) if (onMove) { onMove({ activeIndex, overIndex }) } else { onValueChange?.(arrayMove(value, activeIndex, overIndex)) } } setActiveId(null) onDragEnd?.(event) }} onDragCancel={(event) => { setActiveId?.(null) onDragCancel?.(event) }} collisionDetection={collisionDetection} {...props} > {children} {overlay ? // https://docs.dndkit.com/api-documentation/draggable/drag-overlay#portals createPortal( {overlay}, document.body ) : null} ) } const dropAnimationOpts: DropAnimation = { sideEffects: defaultDropAnimationSideEffects({ styles: { active: { opacity: '0.4', }, }, }), } interface SortableOverlayProps extends React.ComponentPropsWithRef { activeId?: UniqueIdentifier | null } export const SortableOverlay = forwardRef( ({ activeId, dropAnimation = dropAnimationOpts, children, ...props }, ref) => { return ( {activeId ? ( {children} ) : null} ) } ) SortableOverlay.displayName = 'SortableOverlay' interface SortableItemContextProps { attributes: React.HTMLAttributes listeners: DraggableSyntheticListeners | undefined isDragging?: boolean } const SortableItemContext = createContext({ attributes: {}, listeners: undefined, isDragging: false, }) function useSortableItem() { const context = useContext(SortableItemContext) if (!context) { throw new Error('useSortableItem must be used within a SortableItem') } return context } interface SortableItemProps extends Slot.SlotProps { /** * The unique identifier of the item. * @example "item-1" * @type UniqueIdentifier */ value: UniqueIdentifier /** * Specifies whether the item should act as a trigger for the drag-and-drop action. * @default false * @type boolean | undefined */ asTrigger?: boolean /** * Merges the item's props into its immediate child. * @default false * @type boolean | undefined */ asChild?: boolean } export const SortableItem = forwardRef( ({ value, asTrigger, asChild, className, ...props }, ref) => { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: value, }) const context = useMemo( () => ({ attributes, listeners, isDragging, }), [attributes, listeners, isDragging] ) const style: React.CSSProperties = { opacity: isDragging ? 0.5 : 1, transform: CSS.Translate.toString(transform), transition, } const Comp = asChild ? Slot.Slot : 'div' return ( )} style={style} {...(asTrigger ? attributes : {})} {...(asTrigger ? listeners : {})} {...props} /> ) } ) SortableItem.displayName = 'SortableItem' interface SortableDragHandleProps extends ButtonProps { withHandle?: boolean } export const SortableDragHandle = forwardRef( ({ className, ...props }, ref) => { const { attributes, listeners, isDragging } = useSortableItem() return (