From 1a2241c40e10193c5ff7008a7b7b36cc1d855d96 Mon Sep 17 00:00:00 2001
From: joonhoekim <26rote@gmail.com>
Date: Tue, 25 Mar 2025 15:55:45 +0900
Subject: initial commit

---
 components/ui/sortable.tsx | 336 +++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 336 insertions(+)
 create mode 100644 components/ui/sortable.tsx

(limited to 'components/ui/sortable.tsx')

diff --git a/components/ui/sortable.tsx b/components/ui/sortable.tsx
new file mode 100644
index 00000000..fd3dbd92
--- /dev/null
+++ b/components/ui/sortable.tsx
@@ -0,0 +1,336 @@
+"use client"
+
+import * as React from "react"
+import type {
+  DndContextProps,
+  DraggableSyntheticListeners,
+  DropAnimation,
+  UniqueIdentifier,
+} from "@dnd-kit/core"
+import {
+  closestCenter,
+  defaultDropAnimationSideEffects,
+  DndContext,
+  DragOverlay,
+  KeyboardSensor,
+  MouseSensor,
+  TouchSensor,
+  useSensor,
+  useSensors,
+} from "@dnd-kit/core"
+import {
+  restrictToHorizontalAxis,
+  restrictToParentElement,
+  restrictToVerticalAxis,
+} from "@dnd-kit/modifiers"
+import {
+  arrayMove,
+  horizontalListSortingStrategy,
+  SortableContext,
+  useSortable,
+  verticalListSortingStrategy,
+  type SortableContextProps,
+} from "@dnd-kit/sortable"
+import { CSS } from "@dnd-kit/utilities"
+import { Slot, type SlotProps } from "@radix-ui/react-slot"
+
+import { composeRefs } from "@/lib/compose-refs"
+import { cn } from "@/lib/utils"
+import { Button, type ButtonProps } from "@/components/ui/button"
+
+const orientationConfig = {
+  vertical: {
+    modifiers: [restrictToVerticalAxis, restrictToParentElement],
+    strategy: verticalListSortingStrategy,
+  },
+  horizontal: {
+    modifiers: [restrictToHorizontalAxis, restrictToParentElement],
+    strategy: horizontalListSortingStrategy,
+  },
+  mixed: {
+    modifiers: [restrictToParentElement],
+    strategy: undefined,
+  },
+}
+
+interface SortableProps<TData extends { id: UniqueIdentifier }>
+  extends DndContextProps {
+  /**
+   * An array of data items that the sortable component will render.
+   * @example
+   * value={[
+   *   { id: 1, name: 'Item 1' },
+   *   { id: 2, name: 'Item 2' },
+   * ]}
+   */
+  value: TData[]
+
+  /**
+   * An optional callback function that is called when the order of the data items changes.
+   * It receives the new array of items as its argument.
+   * @example
+   * onValueChange={(items) => console.log(items)}
+   */
+  onValueChange?: (items: TData[]) => void
+
+  /**
+   * An optional callback function that is called when an item is moved.
+   * It receives an event object with `activeIndex` and `overIndex` properties, representing the original and new positions of the moved item.
+   * This will override the default behavior of updating the order of the data items.
+   * @type (event: { activeIndex: number; overIndex: number }) => void
+   * @example
+   * onMove={(event) => console.log(`Item moved from index ${event.activeIndex} to index ${event.overIndex}`)}
+   */
+  onMove?: (event: { activeIndex: number; overIndex: number }) => void
+
+  /**
+   * A collision detection strategy that will be used to determine the closest sortable item.
+   * @default closestCenter
+   * @type DndContextProps["collisionDetection"]
+   */
+  collisionDetection?: DndContextProps["collisionDetection"]
+
+  /**
+   * An array of modifiers that will be used to modify the behavior of the sortable component.
+   * @default
+   * [restrictToVerticalAxis, restrictToParentElement]
+   * @type Modifier[]
+   */
+  modifiers?: DndContextProps["modifiers"]
+
+  /**
+   * A sorting strategy that will be used to determine the new order of the data items.
+   * @default verticalListSortingStrategy
+   * @type SortableContextProps["strategy"]
+   */
+  strategy?: SortableContextProps["strategy"]
+
+  /**
+   * Specifies the axis for the drag-and-drop operation. It can be "vertical", "horizontal", or "both".
+   * @default "vertical"
+   * @type "vertical" | "horizontal" | "mixed"
+   */
+  orientation?: "vertical" | "horizontal" | "mixed"
+
+  /**
+   * An optional React node that is rendered on top of the sortable component.
+   * It can be used to display additional information or controls.
+   * @default null
+   * @type React.ReactNode | null
+   * @example
+   * overlay={<Skeleton className="w-full h-8" />}
+   */
+  overlay?: React.ReactNode | null
+}
+
+function Sortable<TData extends { id: UniqueIdentifier }>({
+  value,
+  onValueChange,
+  collisionDetection = closestCenter,
+  modifiers,
+  strategy,
+  onMove,
+  orientation = "vertical",
+  overlay,
+  children,
+  ...props
+}: SortableProps<TData>) {
+  const [activeId, setActiveId] = React.useState<UniqueIdentifier | null>(null)
+  const sensors = useSensors(
+    useSensor(MouseSensor),
+    useSensor(TouchSensor),
+    useSensor(KeyboardSensor)
+  )
+
+  const config = orientationConfig[orientation]
+
+  return (
+    <DndContext
+      modifiers={modifiers ?? config.modifiers}
+      sensors={sensors}
+      onDragStart={({ active }) => setActiveId(active.id)}
+      onDragEnd={({ active, over }) => {
+        if (over && active.id !== over?.id) {
+          const activeIndex = value.findIndex((item) => item.id === active.id)
+          const overIndex = value.findIndex((item) => item.id === over.id)
+
+          if (onMove) {
+            onMove({ activeIndex, overIndex })
+          } else {
+            onValueChange?.(arrayMove(value, activeIndex, overIndex))
+          }
+        }
+        setActiveId(null)
+      }}
+      onDragCancel={() => setActiveId(null)}
+      collisionDetection={collisionDetection}
+      {...props}
+    >
+      <SortableContext items={value} strategy={strategy ?? config.strategy}>
+        {children}
+      </SortableContext>
+      {overlay ? (
+        <SortableOverlay activeId={activeId}>{overlay}</SortableOverlay>
+      ) : null}
+    </DndContext>
+  )
+}
+
+const dropAnimationOpts: DropAnimation = {
+  sideEffects: defaultDropAnimationSideEffects({
+    styles: {
+      active: {
+        opacity: "0.4",
+      },
+    },
+  }),
+}
+
+interface SortableOverlayProps
+  extends React.ComponentPropsWithRef<typeof DragOverlay> {
+  activeId?: UniqueIdentifier | null
+}
+
+const SortableOverlay = React.forwardRef<HTMLDivElement, SortableOverlayProps>(
+  (
+    { activeId, dropAnimation = dropAnimationOpts, children, ...props },
+    ref
+  ) => {
+    return (
+      <DragOverlay dropAnimation={dropAnimation} {...props}>
+        {activeId ? (
+          <SortableItem
+            ref={ref}
+            value={activeId}
+            className="cursor-grabbing"
+            asChild
+          >
+            {children}
+          </SortableItem>
+        ) : null}
+      </DragOverlay>
+    )
+  }
+)
+SortableOverlay.displayName = "SortableOverlay"
+
+interface SortableItemContextProps {
+  attributes: React.HTMLAttributes<HTMLElement>
+  listeners: DraggableSyntheticListeners | undefined
+  isDragging?: boolean
+}
+
+const SortableItemContext = React.createContext<SortableItemContextProps>({
+  attributes: {},
+  listeners: undefined,
+  isDragging: false,
+})
+
+function useSortableItem() {
+  const context = React.useContext(SortableItemContext)
+
+  if (!context) {
+    throw new Error("useSortableItem must be used within a SortableItem")
+  }
+
+  return context
+}
+
+interface SortableItemProps extends SlotProps {
+  /**
+   * The unique identifier of the item.
+   * @example "item-1"
+   * @type UniqueIdentifier
+   */
+  value: UniqueIdentifier
+
+  /**
+   * Specifies whether the item should act as a trigger for the drag-and-drop action.
+   * @default false
+   * @type boolean | undefined
+   */
+  asTrigger?: boolean
+
+  /**
+   * Merges the item's props into its immediate child.
+   * @default false
+   * @type boolean | undefined
+   */
+  asChild?: boolean
+}
+
+const SortableItem = React.forwardRef<HTMLDivElement, SortableItemProps>(
+  ({ value, asTrigger, asChild, className, ...props }, ref) => {
+    const {
+      attributes,
+      listeners,
+      setNodeRef,
+      transform,
+      transition,
+      isDragging,
+    } = useSortable({ id: value })
+
+    const context = React.useMemo<SortableItemContextProps>(
+      () => ({
+        attributes,
+        listeners,
+        isDragging,
+      }),
+      [attributes, listeners, isDragging]
+    )
+    const style: React.CSSProperties = {
+      opacity: isDragging ? 0.5 : 1,
+      transform: CSS.Translate.toString(transform),
+      transition,
+    }
+
+    const Comp = asChild ? Slot : "div"
+
+    return (
+      <SortableItemContext.Provider value={context}>
+        <Comp
+          data-state={isDragging ? "dragging" : undefined}
+          className={cn(
+            "data-[state=dragging]:cursor-grabbing",
+            { "cursor-grab": !isDragging && asTrigger },
+            className
+          )}
+          ref={composeRefs(ref, setNodeRef as React.Ref<HTMLDivElement>)}
+          style={style}
+          {...(asTrigger ? attributes : {})}
+          {...(asTrigger ? listeners : {})}
+          {...props}
+        />
+      </SortableItemContext.Provider>
+    )
+  }
+)
+SortableItem.displayName = "SortableItem"
+
+interface SortableDragHandleProps extends ButtonProps {
+  withHandle?: boolean
+}
+
+const SortableDragHandle = React.forwardRef<
+  HTMLButtonElement,
+  SortableDragHandleProps
+>(({ className, ...props }, ref) => {
+  const { attributes, listeners, isDragging } = useSortableItem()
+
+  return (
+    <Button
+      ref={composeRefs(ref)}
+      data-state={isDragging ? "dragging" : undefined}
+      className={cn(
+        "cursor-grab data-[state=dragging]:cursor-grabbing",
+        className
+      )}
+      {...attributes}
+      {...listeners}
+      {...props}
+    />
+  )
+})
+SortableDragHandle.displayName = "SortableDragHandle"
+
+export { Sortable, SortableDragHandle, SortableItem, SortableOverlay }
-- 
cgit v1.2.3

