Openstatus
www.openstatus.dev
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 };