1"use client";2
3import React, { useEffect, useState } from "react";4import {5 Reorder,6 useDragControls,7 useReducedMotion,8 motion,9} from "motion/react";10import { GripVertical } from "lucide-react";11import { cn } from "@/lib/utils";12
13export type ReorderListItem = {14 id: string;15 label: string;16 meta?: string;17};18
19export type ReorderListProps = {20 items: ReorderListItem[];21 onReorder?: (ids: string[]) => void;22 23 dragFrom?: "handle" | "row";24 disabled?: boolean;25 className?: string;26};27
2829const LAYOUT_SPRING = {30 type: "spring" as const,31 bounce: 0,32 duration: 0.3,33};34
35const DRAG_SPRING = {36 type: "spring" as const,37 bounce: 0,38 duration: 0.32,39};40
41function Row({42 item,43 dragFrom,44 disabled,45 reduce,46}: {47 item: ReorderListItem;48 dragFrom: "handle" | "row";49 disabled?: boolean;50 reduce: boolean | null;51}) {52 const controls = useDragControls();53 const [dragging, setDragging] = useState(false);54 const [pressed, setPressed] = useState(false);55
56 return (57 <Reorder.Item58 value={item}59 id={item.id}60 as="li"61 layout={reduce ? undefined : true}62 dragListener={dragFrom === "row" && !disabled}63 dragControls={controls}64 dragElastic={0.12}65 dragMomentum={false}66 onDragStart={() => {67 setDragging(true);68 setPressed(false);69 }}70 onDragEnd={() => setDragging(false)}71 transition={72 reduce73 ? { duration: 0.12 }74 : {75 layout: LAYOUT_SPRING,76 scale: DRAG_SPRING,77 boxShadow: { duration: 0.2 },78 }79 }80 initial={false}81 animate={82 reduce83 ? { scale: 1 }84 : {85 scale: dragging ? 1.03 : pressed ? 0.96 : 1,86 zIndex: dragging ? 20 : 1,87 }88 }89 style={{90 position: "relative",91 touchAction: dragFrom === "row" ? "none" : undefined,92 }}93 className={cn(94 "list-none will-change-transform",95 "flex items-center gap-2 rounded-md",96 "border border-(--color-rule) bg-(--color-paper) px-2 py-2",97 "outline outline-1 outline-transparent",98 dragging &&99 !reduce &&100 [101 "z-20 outline-black/6",102 "shadow-[0_1px_2px_rgba(0,0,0,0.04),0_8px_24px_rgba(0,0,0,0.12),0_16px_40px_rgba(0,0,0,0.08)]",103 "dark:outline-white/10",104 "dark:shadow-[0_1px_2px_rgba(0,0,0,0.2),0_10px_28px_rgba(0,0,0,0.45),0_20px_48px_rgba(0,0,0,0.3)]",105 ].join(" "),106 !dragging &&107 "shadow-none transition-shadow duration-160 ease-[cubic-bezier(0.23,1,0.32,1)]",108 dragFrom === "row" && !disabled && "cursor-grab active:cursor-grabbing",109 )}110 whileDrag={111 reduce112 ? undefined113 : {114 scale: 1.03,115 cursor: "grabbing",116 }117 }118 >119 <motion.button120 type="button"121 aria-label={`Reorder ${item.label}`}122 disabled={disabled}123 onPointerDown={(e) => {124 if (disabled) return;125 126 setPressed(true);127 if (dragFrom === "handle") {128 controls.start(e);129 }130 }}131 onPointerUp={() => setPressed(false)}132 onPointerCancel={() => setPressed(false)}133 whileTap={disabled || reduce ? undefined : { scale: 0.96 }}134 className={cn(135 "flex size-10 shrink-0 items-center justify-center",136 137 "rounded-[calc(var(--radius-md)-4px)]",138 "text-(--color-ink-muted)",139 "outline-none select-none",140 "transition-colors duration-100 ease-[cubic-bezier(0.23,1,0.32,1)]",141 "hover:bg-(--color-paper-2) hover:text-(--color-ink)",142 "focus-visible:ring-2 focus-visible:ring-(--color-focus)",143 "disabled:pointer-events-none disabled:opacity-40",144 dragFrom === "handle" &&145 !disabled &&146 "cursor-grab touch-none active:cursor-grabbing",147 )}148 >149 <GripVertical className="size-4 stroke-[1.5]" aria-hidden />150 </motion.button>151
152 <div className="min-w-0 flex-1 pr-2">153 <p className="truncate text-sm font-medium text-(--color-ink)">154 {item.label}155 </p>156 {item.meta ? (157 <p className="truncate text-xs text-(--color-ink-muted)">158 {item.meta}159 </p>160 ) : null}161 </div>162 </Reorder.Item>163 );164}165
166export function ReorderList({167 items: itemsProp,168 onReorder,169 dragFrom = "handle",170 disabled,171 className,172}: ReorderListProps) {173 const reduce = useReducedMotion();174 const [items, setItems] = useState(itemsProp);175
176 useEffect(() => {177 setItems(itemsProp);178 }, [itemsProp]);179
180 return (181 <Reorder.Group182 axis="y"183 values={items}184 onReorder={(next) => {185 if (disabled) return;186 setItems(next);187 onReorder?.(next.map((i) => i.id));188 }}189 className={cn("m-0 flex list-none flex-col gap-2 p-0", className)}190 as="ul"191 >192 {items.map((item) => (193 <Row194 key={item.id}195 item={item}196 dragFrom={dragFrom}197 disabled={disabled}198 reduce={reduce}199 />200 ))}201 </Reorder.Group>202 );203}