"use client"; import { type Announcements, closestCenter, closestCorners, DndContext, type DndContextProps, type DragEndEvent, type DraggableAttributes, type DraggableSyntheticListeners, DragOverlay, type DragStartEvent, type DropAnimation, defaultDropAnimationSideEffects, KeyboardSensor, MouseSensor, type ScreenReaderInstructions, TouchSensor, type UniqueIdentifier, useSensor, useSensors, } from "@dnd-kit/core"; import { restrictToHorizontalAxis, restrictToParentElement, restrictToVerticalAxis, } from "@dnd-kit/modifiers"; import { arrayMove, horizontalListSortingStrategy, SortableContext, type SortableContextProps, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { Slot } from "@radix-ui/react-slot"; import * as React from "react"; import * as ReactDOM from "react-dom"; import { useComposedRefs } from "@/hooks/use-composed-refs"; import { cn } from "@/lib/utils"; const orientationConfig = { vertical: { modifiers: [restrictToVerticalAxis, restrictToParentElement], strategy: verticalListSortingStrategy, collisionDetection: closestCenter, }, horizontal: { modifiers: [restrictToHorizontalAxis, restrictToParentElement], strategy: horizontalListSortingStrategy, collisionDetection: closestCenter, }, mixed: { modifiers: [restrictToParentElement], strategy: undefined, collisionDetection: closestCorners, }, }; const ROOT_NAME = "Sortable"; const CONTENT_NAME = "SortableContent"; const ITEM_NAME = "SortableItem"; const ITEM_HANDLE_NAME = "SortableItemHandle"; const OVERLAY_NAME = "SortableOverlay"; interface SortableRootContextValue { id: string; items: UniqueIdentifier[]; modifiers: DndContextProps["modifiers"]; strategy: SortableContextProps["strategy"]; activeId: UniqueIdentifier | null; setActiveId: (id: UniqueIdentifier | null) => void; getItemValue: (item: T) => UniqueIdentifier; flatCursor: boolean; } const SortableRootContext = React.createContext | null>(null); SortableRootContext.displayName = ROOT_NAME; function useSortableContext(consumerName: string) { const context = React.useContext(SortableRootContext); if (!context) { throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``); } return context; } interface GetItemValue { /** * Callback that returns a unique identifier for each sortable item. Required for array of objects. * @example getItemValue={(item) => item.id} */ getItemValue: (item: T) => UniqueIdentifier; } type SortableRootProps = DndContextProps & { value: T[]; onValueChange?: (items: T[]) => void; onMove?: ( event: DragEndEvent & { activeIndex: number; overIndex: number }, ) => void; strategy?: SortableContextProps["strategy"]; orientation?: "vertical" | "horizontal" | "mixed"; flatCursor?: boolean; } & (T extends object ? GetItemValue : Partial>); function SortableRoot(props: SortableRootProps) { const { value, onValueChange, collisionDetection, modifiers, strategy, onMove, orientation = "vertical", flatCursor = false, getItemValue: getItemValueProp, accessibility, ...sortableProps } = props; const id = React.useId(); const [activeId, setActiveId] = React.useState(null); const sensors = useSensors( useSensor(MouseSensor), useSensor(TouchSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), ); const config = React.useMemo( () => orientationConfig[orientation], [orientation], ); const getItemValue = React.useCallback( (item: T): UniqueIdentifier => { if (typeof item === "object" && !getItemValueProp) { throw new Error("getItemValue is required when using array of objects"); } return getItemValueProp ? getItemValueProp(item) : (item as UniqueIdentifier); }, [getItemValueProp], ); const items = React.useMemo(() => { return value.map((item) => getItemValue(item)); }, [value, getItemValue]); const { onDragStart: onDragStartProp, onDragEnd: onDragEndProp, onDragCancel: onDragCancelProp } = sortableProps; const onDragStart = React.useCallback( (event: DragStartEvent) => { onDragStartProp?.(event); if (event.activatorEvent.defaultPrevented) return; setActiveId(event.active.id); }, [onDragStartProp], ); const onDragEnd = React.useCallback( (event: DragEndEvent) => { onDragEndProp?.(event); if (event.activatorEvent.defaultPrevented) return; const { active, over } = event; if (over && active.id !== over?.id) { const activeIndex = value.findIndex( (item) => getItemValue(item) === active.id, ); const overIndex = value.findIndex( (item) => getItemValue(item) === over.id, ); if (onMove) { onMove({ ...event, activeIndex, overIndex }); } else { onValueChange?.(arrayMove(value, activeIndex, overIndex)); } } setActiveId(null); }, [value, onValueChange, onMove, getItemValue, onDragEndProp], ); const onDragCancel = React.useCallback( (event: DragEndEvent) => { onDragCancelProp?.(event); if (event.activatorEvent.defaultPrevented) return; setActiveId(null); }, [onDragCancelProp], ); const announcements: Announcements = React.useMemo( () => ({ onDragStart({ active }) { const activeValue = active.id.toString(); return `Grabbed sortable item "${activeValue}". Current position is ${active.data.current?.sortable.index + 1} of ${value.length}. Use arrow keys to move, space to drop.`; }, onDragOver({ active, over }) { if (over) { const overIndex = over.data.current?.sortable.index ?? 0; const activeIndex = active.data.current?.sortable.index ?? 0; const moveDirection = overIndex > activeIndex ? "down" : "up"; const activeValue = active.id.toString(); return `Sortable item "${activeValue}" moved ${moveDirection} to position ${overIndex + 1} of ${value.length}.`; } return "Sortable item is no longer over a droppable area. Press escape to cancel."; }, onDragEnd({ active, over }) { const activeValue = active.id.toString(); if (over) { const overIndex = over.data.current?.sortable.index ?? 0; return `Sortable item "${activeValue}" dropped at position ${overIndex + 1} of ${value.length}.`; } return `Sortable item "${activeValue}" dropped. No changes were made.`; }, onDragCancel({ active }) { const activeIndex = active.data.current?.sortable.index ?? 0; const activeValue = active.id.toString(); return `Sorting cancelled. Sortable item "${activeValue}" returned to position ${activeIndex + 1} of ${value.length}.`; }, onDragMove({ active, over }) { if (over) { const overIndex = over.data.current?.sortable.index ?? 0; const activeIndex = active.data.current?.sortable.index ?? 0; const moveDirection = overIndex > activeIndex ? "down" : "up"; const activeValue = active.id.toString(); return `Sortable item "${activeValue}" is moving ${moveDirection} to position ${overIndex + 1} of ${value.length}.`; } return "Sortable item is no longer over a droppable area. Press escape to cancel."; }, }), [value], ); const screenReaderInstructions: ScreenReaderInstructions = React.useMemo( () => ({ draggable: ` To pick up a sortable item, press space or enter. While dragging, use the ${orientation === "vertical" ? "up and down" : orientation === "horizontal" ? "left and right" : "arrow"} keys to move the item. Press space or enter again to drop the item in its new position, or press escape to cancel. `, }), [orientation], ); const contextValue = React.useMemo( () => ({ id, items, modifiers: modifiers ?? config.modifiers, strategy: strategy ?? config.strategy, activeId, setActiveId, getItemValue, flatCursor, }), [ id, items, modifiers, strategy, config.modifiers, config.strategy, activeId, getItemValue, flatCursor, ], ); return ( } > ); } const SortableContentContext = React.createContext(false); SortableContentContext.displayName = CONTENT_NAME; interface SortableContentProps extends React.ComponentPropsWithoutRef<"div"> { strategy?: SortableContextProps["strategy"]; children: React.ReactNode; asChild?: boolean; withoutSlot?: boolean; } const SortableContent = React.forwardRef( (props, forwardedRef) => { const { strategy: strategyProp, asChild, withoutSlot, children, ...contentProps } = props; const context = useSortableContext(CONTENT_NAME); const ContentPrimitive = asChild ? Slot : "div"; return ( {withoutSlot ? ( children ) : ( {children} )} ); }, ); SortableContent.displayName = CONTENT_NAME; interface SortableItemContextValue { id: string; attributes: DraggableAttributes; listeners: DraggableSyntheticListeners | undefined; setActivatorNodeRef: (node: HTMLElement | null) => void; isDragging?: boolean; disabled?: boolean; } const SortableItemContext = React.createContext(null); SortableItemContext.displayName = ITEM_NAME; function useSortableItemContext(consumerName: string) { const context = React.useContext(SortableItemContext); if (!context) { throw new Error(`\`${consumerName}\` must be used within \`${ITEM_NAME}\``); } return context; } interface SortableItemProps extends React.ComponentPropsWithoutRef<"div"> { value: UniqueIdentifier; asHandle?: boolean; asChild?: boolean; disabled?: boolean; } const SortableItem = React.forwardRef( (props, forwardedRef) => { const { value, style, asHandle, asChild, disabled, className, ...itemProps } = props; const inSortableContent = React.useContext(SortableContentContext); const inSortableOverlay = React.useContext(SortableOverlayContext); if (!inSortableContent && !inSortableOverlay) { throw new Error( `\`${ITEM_NAME}\` must be used within \`${CONTENT_NAME}\` or \`${OVERLAY_NAME}\``, ); } if (value === "") { throw new Error(`\`${ITEM_NAME}\` value cannot be an empty string`); } const context = useSortableContext(ITEM_NAME); const id = React.useId(); const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging, } = useSortable({ id: value, disabled }); const composedRef = useComposedRefs(forwardedRef, (node) => { if (disabled) return; setNodeRef(node); if (asHandle) setActivatorNodeRef(node); }); const composedStyle = React.useMemo(() => { return { transform: CSS.Translate.toString(transform), transition, ...style, }; }, [transform, transition, style]); const itemContext = React.useMemo( () => ({ id, attributes, listeners, setActivatorNodeRef, isDragging, disabled, }), [id, attributes, listeners, setActivatorNodeRef, isDragging, disabled], ); const ItemPrimitive = asChild ? Slot : "div"; return ( ); }, ); SortableItem.displayName = ITEM_NAME; interface SortableItemHandleProps extends React.ComponentPropsWithoutRef<"button"> { asChild?: boolean; } const SortableItemHandle = React.forwardRef< HTMLButtonElement, SortableItemHandleProps >((props, forwardedRef) => { const { asChild, disabled, className, ...itemHandleProps } = props; const context = useSortableContext(ITEM_HANDLE_NAME); const itemContext = useSortableItemContext(ITEM_HANDLE_NAME); const isDisabled = disabled ?? itemContext.disabled; const composedRef = useComposedRefs(forwardedRef, (node) => { if (!isDisabled) return; itemContext.setActivatorNodeRef(node); }); const HandlePrimitive = asChild ? Slot : "button"; return ( ); }); SortableItemHandle.displayName = ITEM_HANDLE_NAME; const SortableOverlayContext = React.createContext(false); SortableOverlayContext.displayName = OVERLAY_NAME; const dropAnimation: DropAnimation = { sideEffects: defaultDropAnimationSideEffects({ styles: { active: { opacity: "0.4", }, }, }), }; interface SortableOverlayProps extends Omit, "children"> { container?: Element | DocumentFragment | null; children?: | ((params: { value: UniqueIdentifier }) => React.ReactNode) | React.ReactNode; } function SortableOverlay(props: SortableOverlayProps) { const { container: containerProp, children, ...overlayProps } = props; const context = useSortableContext(OVERLAY_NAME); const [mounted, setMounted] = React.useState(false); React.useLayoutEffect(() => setMounted(true), []); const container = containerProp ?? (mounted ? globalThis.document?.body : null); if (!container) return null; return ReactDOM.createPortal( {context.activeId ? typeof children === "function" ? children({ value: context.activeId }) : children : null} , container, ); } export { SortableRoot as Sortable, SortableContent, SortableItem, SortableItemHandle, SortableOverlay };