Openstatus
www.openstatus.dev
1"use client";
2
3import {
4 type Announcements,
5 DndContext,
6 type DndContextProps,
7 type DragEndEvent,
8 DragOverlay,
9 type DraggableSyntheticListeners,
10 type DropAnimation,
11 KeyboardSensor,
12 MouseSensor,
13 type ScreenReaderInstructions,
14 TouchSensor,
15 type UniqueIdentifier,
16 closestCenter,
17 closestCorners,
18 defaultDropAnimationSideEffects,
19 useSensor,
20 useSensors,
21} from "@dnd-kit/core";
22import {
23 restrictToHorizontalAxis,
24 restrictToParentElement,
25 restrictToVerticalAxis,
26} from "@dnd-kit/modifiers";
27import {
28 SortableContext,
29 type SortableContextProps,
30 arrayMove,
31 horizontalListSortingStrategy,
32 sortableKeyboardCoordinates,
33 useSortable,
34 verticalListSortingStrategy,
35} from "@dnd-kit/sortable";
36import { CSS } from "@dnd-kit/utilities";
37import { Slot } from "@radix-ui/react-slot";
38import * as React from "react";
39import * as ReactDOM from "react-dom";
40
41import { composeEventHandlers, useComposedRefs } from "@/lib/composition";
42import { cn } from "@/lib/utils";
43
44const orientationConfig = {
45 vertical: {
46 modifiers: [restrictToVerticalAxis, restrictToParentElement],
47 strategy: verticalListSortingStrategy,
48 collisionDetection: closestCenter,
49 },
50 horizontal: {
51 modifiers: [restrictToHorizontalAxis, restrictToParentElement],
52 strategy: horizontalListSortingStrategy,
53 collisionDetection: closestCenter,
54 },
55 mixed: {
56 modifiers: [restrictToParentElement],
57 strategy: undefined,
58 collisionDetection: closestCorners,
59 },
60};
61
62const ROOT_NAME = "Sortable";
63const CONTENT_NAME = "SortableContent";
64const ITEM_NAME = "SortableItem";
65const ITEM_HANDLE_NAME = "SortableItemHandle";
66const OVERLAY_NAME = "SortableOverlay";
67
68const SORTABLE_ERRORS = {
69 [ROOT_NAME]: `\`${ROOT_NAME}\` components must be within \`${ROOT_NAME}\``,
70 [CONTENT_NAME]: `\`${CONTENT_NAME}\` must be within \`${ROOT_NAME}\``,
71 [ITEM_NAME]: `\`${ITEM_NAME}\` must be within \`${CONTENT_NAME}\``,
72 [ITEM_HANDLE_NAME]: `\`${ITEM_HANDLE_NAME}\` must be within \`${ITEM_NAME}\``,
73 [OVERLAY_NAME]: `\`${OVERLAY_NAME}\` must be within \`${ROOT_NAME}\``,
74} as const;
75
76interface SortableRootContextValue<T> {
77 id: string;
78 items: UniqueIdentifier[];
79 modifiers: DndContextProps["modifiers"];
80 strategy: SortableContextProps["strategy"];
81 activeId: UniqueIdentifier | null;
82 setActiveId: (id: UniqueIdentifier | null) => void;
83 getItemValue: (item: T) => UniqueIdentifier;
84 flatCursor: boolean;
85}
86
87const SortableRootContext =
88 React.createContext<SortableRootContextValue<unknown> | null>(null);
89SortableRootContext.displayName = ROOT_NAME;
90
91function useSortableContext(name: keyof typeof SORTABLE_ERRORS) {
92 const context = React.useContext(SortableRootContext);
93 if (!context) {
94 throw new Error(SORTABLE_ERRORS[name]);
95 }
96 return context;
97}
98
99interface GetItemValue<T> {
100 /**
101 * Callback that returns a unique identifier for each sortable item. Required for array of objects.
102 * @example getItemValue={(item) => item.id}
103 */
104 getItemValue: (item: T) => UniqueIdentifier;
105}
106
107type SortableProps<T> = DndContextProps & {
108 value: T[];
109 onValueChange?: (items: T[]) => void;
110 onMove?: (
111 event: DragEndEvent & { activeIndex: number; overIndex: number },
112 ) => void;
113 strategy?: SortableContextProps["strategy"];
114 orientation?: "vertical" | "horizontal" | "mixed";
115 flatCursor?: boolean;
116} & (T extends object ? GetItemValue<T> : Partial<GetItemValue<T>>);
117
118function Sortable<T>(props: SortableProps<T>) {
119 const {
120 value,
121 onValueChange,
122 collisionDetection,
123 modifiers,
124 strategy,
125 onMove,
126 orientation = "vertical",
127 flatCursor = false,
128 getItemValue: getItemValueProp,
129 accessibility,
130 ...sortableProps
131 } = props;
132 const id = React.useId();
133 const [activeId, setActiveId] = React.useState<UniqueIdentifier | null>(null);
134
135 const sensors = useSensors(
136 useSensor(MouseSensor),
137 useSensor(TouchSensor),
138 useSensor(KeyboardSensor, {
139 coordinateGetter: sortableKeyboardCoordinates,
140 }),
141 );
142 const config = React.useMemo(
143 () => orientationConfig[orientation],
144 [orientation],
145 );
146
147 const getItemValue = React.useCallback(
148 (item: T): UniqueIdentifier => {
149 if (typeof item === "object" && !getItemValueProp) {
150 throw new Error(
151 "getItemValue is required when using array of objects.",
152 );
153 }
154 return getItemValueProp
155 ? getItemValueProp(item)
156 : (item as UniqueIdentifier);
157 },
158 [getItemValueProp],
159 );
160
161 const items = React.useMemo(() => {
162 return value.map((item) => getItemValue(item));
163 }, [value, getItemValue]);
164
165 const onDragEnd = React.useCallback(
166 (event: DragEndEvent) => {
167 const { active, over } = event;
168 if (over && active.id !== over?.id) {
169 const activeIndex = value.findIndex(
170 (item) => getItemValue(item) === active.id,
171 );
172 const overIndex = value.findIndex(
173 (item) => getItemValue(item) === over.id,
174 );
175
176 if (onMove) {
177 onMove({ ...event, activeIndex, overIndex });
178 } else {
179 onValueChange?.(arrayMove(value, activeIndex, overIndex));
180 }
181 }
182 setActiveId(null);
183 },
184 [value, onValueChange, onMove, getItemValue],
185 );
186
187 const announcements: Announcements = React.useMemo(
188 () => ({
189 onDragStart({ active }) {
190 const activeValue = active.id.toString();
191 return `Grabbed sortable item "${activeValue}". Current position is ${
192 active.data.current?.sortable.index + 1
193 } of ${value.length}. Use arrow keys to move, space to drop.`;
194 },
195 onDragOver({ active, over }) {
196 if (over) {
197 const overIndex = over.data.current?.sortable.index ?? 0;
198 const activeIndex = active.data.current?.sortable.index ?? 0;
199 const moveDirection = overIndex > activeIndex ? "down" : "up";
200 const activeValue = active.id.toString();
201 return `Sortable item "${activeValue}" moved ${moveDirection} to position ${
202 overIndex + 1
203 } of ${value.length}.`;
204 }
205 return "Sortable item is no longer over a droppable area. Press escape to cancel.";
206 },
207 onDragEnd({ active, over }) {
208 const activeValue = active.id.toString();
209 if (over) {
210 const overIndex = over.data.current?.sortable.index ?? 0;
211 return `Sortable item "${activeValue}" dropped at position ${
212 overIndex + 1
213 } of ${value.length}.`;
214 }
215 return `Sortable item "${activeValue}" dropped. No changes were made.`;
216 },
217 onDragCancel({ active }) {
218 const activeIndex = active.data.current?.sortable.index ?? 0;
219 const activeValue = active.id.toString();
220 return `Sorting cancelled. Sortable item "${activeValue}" returned to position ${
221 activeIndex + 1
222 } of ${value.length}.`;
223 },
224 onDragMove({ active, over }) {
225 if (over) {
226 const overIndex = over.data.current?.sortable.index ?? 0;
227 const activeIndex = active.data.current?.sortable.index ?? 0;
228 const moveDirection = overIndex > activeIndex ? "down" : "up";
229 const activeValue = active.id.toString();
230 return `Sortable item "${activeValue}" is moving ${moveDirection} to position ${
231 overIndex + 1
232 } of ${value.length}.`;
233 }
234 return "Sortable item is no longer over a droppable area. Press escape to cancel.";
235 },
236 }),
237 [value],
238 );
239
240 const screenReaderInstructions: ScreenReaderInstructions = React.useMemo(
241 () => ({
242 draggable: `
243 To pick up a sortable item, press space or enter.
244 While dragging, use the ${
245 orientation === "vertical"
246 ? "up and down"
247 : orientation === "horizontal"
248 ? "left and right"
249 : "arrow"
250 } keys to move the item.
251 Press space or enter again to drop the item in its new position, or press escape to cancel.
252 `,
253 }),
254 [orientation],
255 );
256
257 const contextValue = React.useMemo(
258 () => ({
259 id,
260 items,
261 modifiers: modifiers ?? config.modifiers,
262 strategy: strategy ?? config.strategy,
263 activeId,
264 setActiveId,
265 getItemValue,
266 flatCursor,
267 }),
268 [
269 id,
270 items,
271 modifiers,
272 strategy,
273 config.modifiers,
274 config.strategy,
275 activeId,
276 getItemValue,
277 flatCursor,
278 ],
279 );
280
281 return (
282 <SortableRootContext.Provider
283 value={contextValue as SortableRootContextValue<unknown>}
284 >
285 <DndContext
286 collisionDetection={collisionDetection ?? config.collisionDetection}
287 modifiers={modifiers ?? config.modifiers}
288 sensors={sensors}
289 {...sortableProps}
290 id={id}
291 onDragStart={composeEventHandlers(
292 sortableProps.onDragStart,
293 ({ active }) => setActiveId(active.id),
294 )}
295 onDragEnd={composeEventHandlers(sortableProps.onDragEnd, onDragEnd)}
296 onDragCancel={composeEventHandlers(sortableProps.onDragCancel, () =>
297 setActiveId(null),
298 )}
299 accessibility={{
300 announcements,
301 screenReaderInstructions,
302 ...accessibility,
303 }}
304 />
305 </SortableRootContext.Provider>
306 );
307}
308
309const SortableContentContext = React.createContext<boolean>(false);
310SortableContentContext.displayName = CONTENT_NAME;
311
312interface SortableContentProps extends React.ComponentPropsWithoutRef<"div"> {
313 strategy?: SortableContextProps["strategy"];
314 children: React.ReactNode;
315 asChild?: boolean;
316 withoutSlot?: boolean;
317}
318
319const SortableContent = React.forwardRef<HTMLDivElement, SortableContentProps>(
320 (props, forwardedRef) => {
321 const {
322 strategy: strategyProp,
323 asChild,
324 withoutSlot,
325 children,
326 ...contentProps
327 } = props;
328 const context = useSortableContext(CONTENT_NAME);
329
330 const ContentPrimitive = asChild ? Slot : "div";
331
332 return (
333 <SortableContentContext.Provider value={true}>
334 <SortableContext
335 items={context.items}
336 strategy={strategyProp ?? context.strategy}
337 >
338 {withoutSlot ? (
339 children
340 ) : (
341 <ContentPrimitive {...contentProps} ref={forwardedRef}>
342 {children}
343 </ContentPrimitive>
344 )}
345 </SortableContext>
346 </SortableContentContext.Provider>
347 );
348 },
349);
350SortableContent.displayName = CONTENT_NAME;
351
352interface SortableItemContextValue {
353 id: string;
354 attributes: React.HTMLAttributes<HTMLElement>;
355 listeners: DraggableSyntheticListeners | undefined;
356 setActivatorNodeRef: (node: HTMLElement | null) => void;
357 isDragging?: boolean;
358 disabled?: boolean;
359}
360
361const SortableItemContext =
362 React.createContext<SortableItemContextValue | null>(null);
363SortableItemContext.displayName = ITEM_NAME;
364
365interface SortableItemProps extends React.ComponentPropsWithoutRef<"div"> {
366 value: UniqueIdentifier;
367 asHandle?: boolean;
368 asChild?: boolean;
369 disabled?: boolean;
370}
371
372const SortableItem = React.forwardRef<HTMLDivElement, SortableItemProps>(
373 (props, forwardedRef) => {
374 const {
375 value,
376 style,
377 asHandle,
378 asChild,
379 disabled,
380 className,
381 ...itemProps
382 } = props;
383 const inSortableContent = React.useContext(SortableContentContext);
384 const inSortableOverlay = React.useContext(SortableOverlayContext);
385
386 if (!inSortableContent && !inSortableOverlay) {
387 throw new Error(SORTABLE_ERRORS[ITEM_NAME]);
388 }
389
390 if (value === "") {
391 throw new Error(`\`${ITEM_NAME}\` value cannot be an empty string`);
392 }
393
394 const context = useSortableContext(ITEM_NAME);
395 const id = React.useId();
396 const {
397 attributes,
398 listeners,
399 setNodeRef,
400 setActivatorNodeRef,
401 transform,
402 transition,
403 isDragging,
404 } = useSortable({ id: value, disabled });
405
406 const composedRef = useComposedRefs(forwardedRef, (node) => {
407 if (disabled) return;
408 setNodeRef(node);
409 if (asHandle) setActivatorNodeRef(node);
410 });
411
412 const composedStyle = React.useMemo<React.CSSProperties>(() => {
413 return {
414 transform: CSS.Translate.toString(transform),
415 transition,
416 ...style,
417 };
418 }, [transform, transition, style]);
419
420 const itemContext = React.useMemo<SortableItemContextValue>(
421 () => ({
422 id,
423 attributes,
424 listeners,
425 setActivatorNodeRef,
426 isDragging,
427 disabled,
428 }),
429 [id, attributes, listeners, setActivatorNodeRef, isDragging, disabled],
430 );
431
432 const ItemPrimitive = asChild ? Slot : "div";
433
434 return (
435 <SortableItemContext.Provider value={itemContext}>
436 <ItemPrimitive
437 id={id}
438 data-dragging={isDragging ? "" : undefined}
439 {...itemProps}
440 {...(asHandle ? attributes : {})}
441 {...(asHandle ? listeners : {})}
442 tabIndex={disabled ? undefined : 0}
443 ref={composedRef}
444 style={composedStyle}
445 className={cn(
446 "focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1",
447 {
448 "touch-none select-none": asHandle,
449 "cursor-default": context.flatCursor,
450 "data-dragging:cursor-grabbing": !context.flatCursor,
451 "cursor-grab": !isDragging && asHandle && !context.flatCursor,
452 "opacity-50": isDragging,
453 "pointer-events-none opacity-50": disabled,
454 },
455 className,
456 )}
457 />
458 </SortableItemContext.Provider>
459 );
460 },
461);
462SortableItem.displayName = ITEM_NAME;
463
464interface SortableItemHandleProps
465 extends React.ComponentPropsWithoutRef<"button"> {
466 asChild?: boolean;
467}
468
469const SortableItemHandle = React.forwardRef<
470 HTMLButtonElement,
471 SortableItemHandleProps
472>((props, forwardedRef) => {
473 const { asChild, disabled, className, ...itemHandleProps } = props;
474 const itemContext = React.useContext(SortableItemContext);
475 if (!itemContext) {
476 throw new Error(SORTABLE_ERRORS[ITEM_HANDLE_NAME]);
477 }
478 const context = useSortableContext(ITEM_HANDLE_NAME);
479
480 const isDisabled = disabled ?? itemContext.disabled;
481
482 const composedRef = useComposedRefs(forwardedRef, (node) => {
483 if (!isDisabled) return;
484 itemContext.setActivatorNodeRef(node);
485 });
486
487 const HandlePrimitive = asChild ? Slot : "button";
488
489 return (
490 <HandlePrimitive
491 type="button"
492 aria-controls={itemContext.id}
493 data-dragging={itemContext.isDragging ? "" : undefined}
494 {...itemHandleProps}
495 {...itemContext.attributes}
496 {...itemContext.listeners}
497 ref={composedRef}
498 className={cn(
499 "select-none disabled:pointer-events-none disabled:opacity-50",
500 context.flatCursor
501 ? "cursor-default"
502 : "cursor-grab data-dragging:cursor-grabbing",
503 className,
504 )}
505 disabled={isDisabled}
506 />
507 );
508});
509SortableItemHandle.displayName = ITEM_HANDLE_NAME;
510
511const SortableOverlayContext = React.createContext(false);
512SortableOverlayContext.displayName = OVERLAY_NAME;
513
514const dropAnimation: DropAnimation = {
515 sideEffects: defaultDropAnimationSideEffects({
516 styles: {
517 active: {
518 opacity: "0.4",
519 },
520 },
521 }),
522};
523
524interface SortableOverlayProps
525 extends Omit<React.ComponentPropsWithoutRef<typeof DragOverlay>, "children"> {
526 container?: Element | DocumentFragment | null;
527 children?:
528 | ((params: { value: UniqueIdentifier }) => React.ReactNode)
529 | React.ReactNode;
530}
531
532function SortableOverlay(props: SortableOverlayProps) {
533 const { container: containerProp, children, ...overlayProps } = props;
534 const context = useSortableContext(OVERLAY_NAME);
535
536 const [mounted, setMounted] = React.useState(false);
537 React.useLayoutEffect(() => setMounted(true), []);
538
539 const container =
540 containerProp ?? (mounted ? globalThis.document?.body : null);
541
542 if (!container) return null;
543
544 return ReactDOM.createPortal(
545 <DragOverlay
546 dropAnimation={dropAnimation}
547 modifiers={context.modifiers}
548 className={cn(!context.flatCursor && "cursor-grabbing")}
549 {...overlayProps}
550 >
551 <SortableOverlayContext.Provider value={true}>
552 {context.activeId
553 ? typeof children === "function"
554 ? children({ value: context.activeId })
555 : children
556 : null}
557 </SortableOverlayContext.Provider>
558 </DragOverlay>,
559 container,
560 );
561}
562
563const Root = Sortable;
564const Content = SortableContent;
565const Item = SortableItem;
566const ItemHandle = SortableItemHandle;
567const Overlay = SortableOverlay;
568
569export {
570 Root,
571 Content,
572 Item,
573 ItemHandle,
574 Overlay,
575 //
576 Sortable,
577 SortableContent,
578 SortableItem,
579 SortableItemHandle,
580 SortableOverlay,
581};