Openstatus www.openstatus.dev
at 4c0f4c00a38753a5d0dfd7e7b7b7706dec6f1503 274 lines 7.4 kB view raw
1"use client"; 2 3import * as React from "react"; 4import type { 5 DndContextProps, 6 DraggableSyntheticListeners, 7 DropAnimation, 8 UniqueIdentifier, 9} from "@dnd-kit/core"; 10import { 11 closestCenter, 12 defaultDropAnimationSideEffects, 13 DndContext, 14 DragOverlay, 15 KeyboardSensor, 16 MouseSensor, 17 TouchSensor, 18 useSensor, 19 useSensors, 20} from "@dnd-kit/core"; 21import { 22 restrictToParentElement, 23 restrictToVerticalAxis, 24} from "@dnd-kit/modifiers"; 25import { 26 arrayMove, 27 SortableContext, 28 useSortable, 29 verticalListSortingStrategy, 30} from "@dnd-kit/sortable"; 31import type { SortableContextProps } from "@dnd-kit/sortable"; 32import { CSS } from "@dnd-kit/utilities"; 33import { Slot } from "@radix-ui/react-slot"; 34import type { SlotProps } from "@radix-ui/react-slot"; 35 36import { composeRefs } from "../lib/compose-refs"; 37import { cn } from "../lib/utils"; 38import { Button } from "./button"; 39import type { ButtonProps } from "./button"; 40 41interface SortableProps<TData extends { id: UniqueIdentifier }> 42 extends DndContextProps { 43 /** 44 * An array of data items that the sortable component will render. 45 * @example 46 * value={[ 47 * { id: 1, name: 'Item 1' }, 48 * { id: 2, name: 'Item 2' }, 49 * ]} 50 */ 51 value: TData[]; 52 53 /** 54 * An optional callback function that is called when the order of the data items changes. 55 * It receives the new array of items as its argument. 56 * @example 57 * onValueChange={(items) => console.log(items)} 58 */ 59 onValueChange?: (items: TData[]) => void; 60 61 /** 62 * An optional callback function that is called when an item is moved. 63 * It receives an event object with `activeIndex` and `overIndex` properties, representing the original and new positions of the moved item. 64 * This will override the default behavior of updating the order of the data items. 65 * @type (event: { activeIndex: number; overIndex: number }) => void 66 * @example 67 * onMove={(event) => console.log(`Item moved from index ${event.activeIndex} to index ${event.overIndex}`)} 68 */ 69 onMove?: (event: { activeIndex: number; overIndex: number }) => void; 70 71 /** 72 * A collision detection strategy that will be used to determine the closest sortable item. 73 * @default closestCenter 74 * @type DndContextProps["collisionDetection"] 75 */ 76 collisionDetection?: DndContextProps["collisionDetection"]; 77 78 /** 79 * An array of modifiers that will be used to modify the behavior of the sortable component. 80 * @default 81 * [restrictToVerticalAxis, restrictToParentElement] 82 * @type Modifier[] 83 */ 84 modifiers?: DndContextProps["modifiers"]; 85 86 /** 87 * A sorting strategy that will be used to determine the new order of the data items. 88 * @default verticalListSortingStrategy 89 * @type SortableContextProps["strategy"] 90 */ 91 strategy?: SortableContextProps["strategy"]; 92 93 /** 94 * An optional React node that is rendered on top of the sortable component. 95 * It can be used to display additional information or controls. 96 * @default null 97 * @type React.ReactNode | null 98 * @example 99 * overlay={<Skeleton className="w-full h-8" />} 100 */ 101 overlay?: React.ReactNode | null; 102} 103 104function Sortable<TData extends { id: UniqueIdentifier }>({ 105 value, 106 onValueChange, 107 collisionDetection = closestCenter, 108 modifiers = [restrictToVerticalAxis, restrictToParentElement], 109 strategy = verticalListSortingStrategy, 110 onMove, 111 children, 112 overlay, 113 ...props 114}: SortableProps<TData>) { 115 const [activeId, setActiveId] = React.useState<UniqueIdentifier | null>(null); 116 117 const sensors = useSensors( 118 useSensor(MouseSensor), 119 useSensor(TouchSensor), 120 useSensor(KeyboardSensor), 121 ); 122 123 return ( 124 <DndContext 125 modifiers={modifiers} 126 sensors={sensors} 127 onDragStart={({ active }) => setActiveId(active.id)} 128 onDragEnd={({ active, over }) => { 129 if (over && active.id !== over?.id) { 130 const activeIndex = value.findIndex((item) => item.id === active.id); 131 const overIndex = value.findIndex((item) => item.id === over.id); 132 133 if (onMove) { 134 onMove({ activeIndex, overIndex }); 135 } else { 136 onValueChange?.(arrayMove(value, activeIndex, overIndex)); 137 } 138 } 139 setActiveId(null); 140 }} 141 onDragCancel={() => setActiveId(null)} 142 collisionDetection={collisionDetection} 143 {...props} 144 > 145 <SortableContext items={value} strategy={strategy}> 146 {children} 147 </SortableContext> 148 {overlay ? ( 149 <SortableOverlay activeId={activeId}>{overlay}</SortableOverlay> 150 ) : null} 151 </DndContext> 152 ); 153} 154 155const dropAnimationOpts: DropAnimation = { 156 sideEffects: defaultDropAnimationSideEffects({ 157 styles: { 158 active: { 159 opacity: "0.4", 160 }, 161 }, 162 }), 163}; 164 165interface SortableOverlayProps 166 extends React.ComponentPropsWithRef<typeof DragOverlay> { 167 activeId?: UniqueIdentifier | null; 168} 169 170function SortableOverlay({ 171 activeId, 172 dropAnimation = dropAnimationOpts, 173 children, 174 ...props 175}: SortableOverlayProps) { 176 return ( 177 <DragOverlay dropAnimation={dropAnimation} {...props}> 178 {activeId ? ( 179 <SortableItem value={activeId} asChild> 180 {children} 181 </SortableItem> 182 ) : null} 183 </DragOverlay> 184 ); 185} 186 187interface SortableItemContextProps { 188 attributes: React.HTMLAttributes<HTMLElement>; 189 listeners: DraggableSyntheticListeners | undefined; 190} 191 192const SortableItemContext = React.createContext<SortableItemContextProps>({ 193 attributes: {}, 194 listeners: undefined, 195}); 196 197function useSortableItem() { 198 const context = React.useContext(SortableItemContext); 199 200 if (!context) { 201 throw new Error("useSortableItem must be used within a SortableItem"); 202 } 203 204 return context; 205} 206 207interface SortableItemProps extends SlotProps { 208 value: UniqueIdentifier; 209 asChild?: boolean; 210} 211 212const SortableItem = React.forwardRef<HTMLDivElement, SortableItemProps>( 213 ({ asChild, className, value, ...props }, ref) => { 214 const { 215 attributes, 216 listeners, 217 setNodeRef, 218 transform, 219 transition, 220 isDragging, 221 } = useSortable({ id: value }); 222 223 const context = React.useMemo( 224 () => ({ 225 attributes, 226 listeners, 227 }), 228 [attributes, listeners], 229 ); 230 const style: React.CSSProperties = { 231 opacity: isDragging ? 0.4 : undefined, 232 transform: CSS.Translate.toString(transform), 233 transition, 234 }; 235 236 const Comp = asChild ? Slot : "div"; 237 238 return ( 239 <SortableItemContext.Provider value={context}> 240 <Comp 241 className={cn(isDragging && "cursor-grabbing", className)} 242 ref={composeRefs(ref, setNodeRef as React.Ref<HTMLDivElement>)} 243 style={style} 244 {...props} 245 /> 246 </SortableItemContext.Provider> 247 ); 248 }, 249); 250SortableItem.displayName = "SortableItem"; 251 252interface SortableDragHandleProps extends ButtonProps { 253 withHandle?: boolean; 254} 255 256const SortableDragHandle = React.forwardRef< 257 HTMLButtonElement, 258 SortableDragHandleProps 259>(({ className, ...props }, ref) => { 260 const { attributes, listeners } = useSortableItem(); 261 262 return ( 263 <Button 264 ref={composeRefs(ref)} 265 className={cn("cursor-grab", className)} 266 {...attributes} 267 {...listeners} 268 {...props} 269 /> 270 ); 271}); 272SortableDragHandle.displayName = "SortableDragHandle"; 273 274export { Sortable, SortableDragHandle, SortableItem, SortableOverlay };