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