This document outlines the steps to create SortableList
component styled withTailwind CSS and using some npm dependency libraries.
Node.js
and npm
installed on your machine.Tailwind CSS
installed in your project.CVA(class-variance-authority)
is a utility for managing CSS class names based on various conditions.clsx
is a tiny utility for constructing className strings conditionally.1// sortable-list.component.tsx
2import clsx from "clsx";
3import { GripIcon } from "lucide-react";
4import { useEffect, useRef } from "react";
5
6interface DataType {
7 id: string;
8 [key: string]: any;
9}
10
11export interface SortableListProps {
12 className?: string;
13 data: DataType[];
14 renderItem: (item: DataType) => React.ReactNode;
15 onChange?: (orderedIds: string[]) => void;
16}
17
18const SortableList: React.FC<SortableListProps> = ({
19 data,
20 className,
21 renderItem,
22 onChange,
23}) => {
24 const sortableListRef = useRef<HTMLUListElement>(null);
25 const orderedIds = useRef<string[]>(data.map((item) => item.id));
26
27 useEffect(() => {
28 const sortableList = sortableListRef.current!;
29 const items = sortableList.querySelectorAll("li");
30
31 const handleDragStart = (item: HTMLLIElement) => {
32 setTimeout(() => {
33 item.classList.add("dragging");
34 const child = item.querySelector("div")!;
35 child.style.opacity = "0";
36 }, 0);
37 };
38
39 const handleDragEnd = (item: HTMLLIElement) => {
40 item.classList.remove("dragging");
41 const child = item.querySelector("div")!;
42 child.style.opacity = "1";
43 };
44
45 items.forEach((item) => {
46 item.ondragstart = () => handleDragStart(item);
47 item.ondragend = () => handleDragEnd(item);
48 });
49
50 return () => {
51 items.forEach((item) => {
52 item.ondragstart = null;
53 item.ondragend = null;
54 });
55 };
56 }, []);
57
58 useEffect(() => {
59 const sortableList = sortableListRef.current!;
60
61 const initSortableList = (e: DragEvent) => {
62 e.preventDefault();
63
64 const listItems = Array.from(sortableList.querySelectorAll("li"));
65 const orderedList = listItems
66 .map((item) => {
67 const id = item.getAttribute("data-id") as string;
68 return id;
69 })
70 .filter((item) => item !== undefined);
71
72 const draggingItem = sortableList.querySelector(".dragging");
73
74 if (!draggingItem) return;
75
76 const siblings = Array.from(
77 sortableList.querySelectorAll("li:not(.dragging)"),
78 );
79
80 const nextSibling = siblings.find((sibling) => {
81 const rect = sibling.getBoundingClientRect();
82 return e.clientY <= rect.top + rect.height / 2;
83 });
84
85 sortableList.insertBefore(draggingItem, nextSibling || null);
86
87 if (orderedIds.current.join(",") !== orderedList.join(",")) {
88 orderedIds.current = orderedList;
89 onChange?.(orderedList);
90 }
91 };
92
93 const preventDragEnter = (e: DragEvent) => {
94 e.preventDefault();
95 };
96
97 sortableList.addEventListener("dragover", initSortableList);
98 sortableList.addEventListener("dragenter", preventDragEnter);
99
100 return () => {
101 sortableList.removeEventListener("dragover", initSortableList);
102 sortableList.removeEventListener("dragenter", preventDragEnter);
103 };
104
105 // eslint-disable-next-line react-hooks/exhaustive-deps
106 }, []);
107
108 return (
109 <ul
110 ref={sortableListRef}
111 className={clsx("grid grid-cols-1 gap-3", className)}
112 >
113 {data.map((item) => (
114 <li
115 key={item.id}
116 data-id={item.id}
117 draggable
118 className="flex cursor-move list-none items-center justify-between rounded-md border border-neutral-300 bg-white px-3 py-2 dark:border-neutral-600 dark:bg-neutral-600"
119 >
120 <div className="flex w-full items-center">
121 <div className="mr-2 flex-auto">{renderItem(item)}</div>
122 <GripIcon width={16} height={16} />
123 </div>
124 </li>
125 ))}
126 </ul>
127 );
128};
129
130export default SortableList;
131