summaryrefslogtreecommitdiff
path: root/components/ui/sortable.tsx
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-03-26 00:37:41 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-03-26 00:37:41 +0000
commite0dfb55c5457aec489fc084c4567e791b4c65eb1 (patch)
tree68543a65d88f5afb3a0202925804103daa91bc6f /components/ui/sortable.tsx
3/25 까지의 대표님 작업사항
Diffstat (limited to 'components/ui/sortable.tsx')
-rw-r--r--components/ui/sortable.tsx336
1 files changed, 336 insertions, 0 deletions
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 }