"use client" import * as React from "react" import { Children, cloneElement, createContext, CSSProperties, isValidElement, ReactElement, ReactNode, useCallback, useContext, useLayoutEffect, useMemo, useState, } from "react" import { mergeProps } from "@base-ui/react/merge-props" import { useRender } from "@base-ui/react/use-render" import { defaultDropAnimationSideEffects, DndContext, DragEndEvent, DragOverlay, DragStartEvent, DropAnimation, KeyboardSensor, MeasuringStrategy, Modifiers, MouseSensor, TouchSensor, UniqueIdentifier, useSensor, useSensors, type DraggableSyntheticListeners, } from "@dnd-kit/core" import { arrayMove, defaultAnimateLayoutChanges, rectSortingStrategy, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, type AnimateLayoutChanges, } from "@dnd-kit/sortable" import { CSS } from "@dnd-kit/utilities" import { createPortal } from "react-dom" import { cn } from "@/lib/utils" // Sortable Item Context const SortableItemContext = createContext<{ listeners: DraggableSyntheticListeners | undefined isDragging?: boolean disabled?: boolean }>({ listeners: undefined, isDragging: false, disabled: false, }) const IsOverlayContext = createContext(false) const SortableInternalContext = createContext<{ activeId: UniqueIdentifier | null modifiers?: Modifiers }>({ activeId: null, modifiers: undefined, }) const animateLayoutChanges: AnimateLayoutChanges = (args) => defaultAnimateLayoutChanges({ ...args, wasDragging: true }) const dropAnimationConfig: DropAnimation = { sideEffects: defaultDropAnimationSideEffects({ styles: { active: { opacity: "0.4", }, }, }), } // Multipurpose Sortable Component export interface SortableRootProps extends Omit< useRender.ComponentProps<"div">, "onDragStart" | "onDragEnd" | "children" > { value: T[] onValueChange: (value: T[]) => void getItemValue: (item: T) => string children: ReactNode onMove?: (event: { event: DragEndEvent activeIndex: number overIndex: number }) => void strategy?: "horizontal" | "vertical" | "grid" onDragStart?: (event: DragStartEvent) => void onDragEnd?: (event: DragEndEvent) => void modifiers?: Modifiers } function Sortable({ value, onValueChange, getItemValue, className, render, onMove, strategy = "vertical", onDragStart, onDragEnd, modifiers, children, ...props }: SortableRootProps) { const [activeId, setActiveId] = useState(null) const [mounted, setMounted] = useState(false) useLayoutEffect(() => setMounted(true), []) const sensors = useSensors( useSensor(MouseSensor, { activationConstraint: { distance: 10, }, }), useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 5, }, }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }) ) const handleDragStart = useCallback( (event: DragStartEvent) => { setActiveId(event.active.id) onDragStart?.(event) }, [onDragStart] ) const handleDragEnd = useCallback( (event: DragEndEvent) => { const { active, over } = event setActiveId(null) onDragEnd?.(event) if (!over) return // Handle item reordering const activeIndex = value.findIndex( (item: T) => getItemValue(item) === active.id ) const overIndex = value.findIndex( (item: T) => getItemValue(item) === over.id ) if (activeIndex !== overIndex) { if (onMove) { onMove({ event, activeIndex, overIndex }) } else { const newValue = arrayMove(value, activeIndex, overIndex) onValueChange(newValue) } } }, [value, getItemValue, onValueChange, onMove, onDragEnd] ) const handleDragCancel = useCallback(() => { setActiveId(null) }, []) const getStrategy = () => { switch (strategy) { case "horizontal": return rectSortingStrategy case "grid": return rectSortingStrategy case "vertical": default: return verticalListSortingStrategy } } const itemIds = useMemo(() => value.map(getItemValue), [value, getItemValue]) const contextValue = useMemo( () => ({ activeId, modifiers }), [activeId, modifiers] ) const defaultProps = { "data-slot": "sortable", "data-dragging": activeId !== null, className: cn(activeId !== null && "cursor-grabbing!", className), children, } // Find the active child for the overlay const overlayContent = useMemo(() => { if (!activeId) return null let result: ReactNode = null Children.forEach(children, (child) => { if (isValidElement(child) && (child.props as any).value === activeId) { result = cloneElement(child as ReactElement, { ...(child.props as any), className: cn((child.props as any).className, "z-50"), }) } }) return result }, [activeId, children]) return ( {useRender({ defaultTagName: "div", render, props: mergeProps<"div">(defaultProps, props), })} {mounted && createPortal( {overlayContent} , document.body )} ) } export interface SortableItemProps extends useRender.ComponentProps<"div"> { value: string disabled?: boolean } function SortableItem({ value, className, render, disabled, ...props }: SortableItemProps) { const isOverlay = useContext(IsOverlayContext) const { setNodeRef, transform, transition, attributes, listeners, isDragging: isSortableDragging, } = useSortable({ id: value, disabled: disabled || isOverlay, animateLayoutChanges, }) if (isOverlay) { const defaultProps = { "data-slot": "sortable-item", "data-value": value, "data-dragging": true, className: cn(className), children: props.children, } return ( {useRender({ defaultTagName: "div", render, props: mergeProps<"div">(defaultProps, props), })} ) } const style = { transition, transform: CSS.Transform.toString(transform), } as CSSProperties const defaultProps = { "data-slot": "sortable-item", "data-value": value, "data-dragging": isSortableDragging, "data-disabled": disabled, ref: setNodeRef, style, ...attributes, className: cn( isSortableDragging && "opacity-50 z-50", disabled && "opacity-50", className ), children: props.children, } return ( {useRender({ defaultTagName: "div", render, props: mergeProps<"div">(defaultProps, props), })} ) } export interface SortableItemHandleProps extends useRender.ComponentProps<"div"> { cursor?: boolean } function SortableItemHandle({ className, render, cursor = true, ...props }: SortableItemHandleProps) { const { listeners, isDragging, disabled } = useContext(SortableItemContext) const defaultProps = { "data-slot": "sortable-item-handle", "data-dragging": isDragging, "data-disabled": disabled, ...listeners, className: cn( cursor && (isDragging ? "cursor-grabbing!" : "cursor-grab!"), className ), children: props.children, } return useRender({ defaultTagName: "div", render, props: mergeProps<"div">(defaultProps, props), }) } export interface SortableOverlayProps extends Omit< React.ComponentProps, "children" > { children?: ReactNode | ((params: { value: UniqueIdentifier }) => ReactNode) } function SortableOverlay({ children, className, ...props }: SortableOverlayProps) { const { activeId, modifiers } = useContext(SortableInternalContext) const [mounted, setMounted] = useState(false) useLayoutEffect(() => setMounted(true), []) const content = activeId && children ? typeof children === "function" ? children({ value: activeId }) : children : null if (!mounted) return null return createPortal( {content} , document.body ) } export { Sortable, SortableItem, SortableItemHandle, SortableOverlay }