[Next.js] dnd-kit์ ํ์ฉํ ์นธ๋ฐ(Kanban) ๋ณด๋ ๋๋๊ทธ ์ค ๋๋กญ ๊ตฌํ
์ผ๋ฐ์ ์ผ๋ก ์นธ๋ฐ ๋ณด๋๋ ๋๋๊ทธ ์ค ๋๋กญ์ ํตํด ์ปฌ๋ผ์ด๋ ํ์คํฌ ์นด๋์ ์์๋ฅผ ์์ ๋กญ๊ฒ ๋ณ๊ฒฝํ ์ ์๋ค. ์ด๋ฌํ ๋๋๊ทธ ์ค ๋๋กญ ์ํธ์์ฉ์ dnd-kit ์ด๋ผ๋ ๋ผ์ด๋ธ๋ฌ๋ฆฌ์์ ์ ๊ณตํ๋ Sortable ํ๋ฆฌ์ ์ ์ด์ฉํ๋ฉด ๋ ์ฝ๊ฒ ๊ตฌํํ ์ ์๋ค. dnd-kit์ ์ฝ์ด ํฌ๊ธฐ๊ฐ 10kb ์ ๋๋ก ๊ฐ๋ณ๊ณ ์ธ๋ถ ์์กด์ฑ์ด ์๋ ์ฅ์ ์ด ์๋ค. ๋๋๊ทธ ์ ํ, ์ ๋๋ฉ์ด์ , ์ถฉ๋ ๊ฐ์ง ๋ฑ์ ์ปค์คํฐ๋ง์ด์ง ํ ์๋ ์๋ค.
dnd-kit์ ์ด์ฉํด ์นธ๋ฐ ๋ณด๋๋ฅผ ๊ตฌํํ๋ฉด์ ๋น๊ต์ ๊น๋ค๋กญ๋ค๊ณ ๋๊ผ๋ ๋ถ๋ถ๋ค์ ์ ๋ฆฌํด ๋ดค๋ค. ์ฝ๋๋ ์๋ ๋ ํฌ์งํ ๋ฆฌ์์ ํ์ธํ ์ ์๋ค.
- ๋ ํฌ์งํ ๋ฆฌ: https://github.com/romantech/simple-kanban
- ๋ฐ๋ชจ ํ์ด์ง: https://kanban.romantech.net
์นธ๋ฐ ๋ฐ์ดํฐ ๋ชจ๋ธ
ID ๊ธฐ๋ฐ ์ฐธ์กฐ ๊ตฌ์กฐ๋ฅผ ์ฌ์ฉํ๋ฉด ์ํฐํฐ ๊ฐ ๊ด๊ณ๋ฅผ ์ ์งํ๋ฉด์ ๋น ๋ฅธ ์กฐํ ์ฑ๋ฅ์ ๋ณด์ฅํ ์ ์๋ค. ๊ฐ ์ํฐํฐ๋ ๊ณ ์ ํ id
๋ฅผ ๊ฐ์ง๋ฉฐ, ์ธ๋ ํค(boardId
, columnId
)๋ฅผ ์ฌ์ฉํ์ฌ ์ฐธ์กฐ ๋ฌด๊ฒฐ์ฑ์ ์ ์งํ๋ค. ์นธ๋ฐ ๋ณด๋๋ ์ฌ๋ฌ ๊ฐ์ ์ปฌ๋ผ์ ํฌํจํ๊ณ , ๊ฐ ์ปฌ๋ผ์ ๋ค์ ์ฌ๋ฌ ๊ฐ์ ํ์คํฌ๋ก ๊ตฌ์ฑ๋๋ ๊ณ์ธต์ ์ธ ๊ตฌ์กฐ๋ฅผ ๊ฐ์ง๋ค.
Record<TaskId, TaskDef>
ํํ๋ก ์ ์ฅํ๋ฉด O(1) ์๊ฐ ๋ณต์ก๋๋ก ๋ฐ์ดํฐ ์กฐํ ๊ฐ๋ฅboardId
,columnId
๊ฐ์ FK(Foreign Key, ์ธ๋ ํค)๋ฅผ ์ฌ์ฉํ์ฌ ์ฐธ์กฐ ๋ฌด๊ฒฐ์ฑ ๋ณด์ฅ- ํ์์ ๋ฐ๋ผ
assigneeId
,priority
๊ฐ์ ํ๋๋ฅผ ์ฝ๊ฒ ์ถ๊ฐํ ์ ์์
๋๋๊ทธ ์ค ๋๋กญ
๋ฆฌ์คํธ๋ฅผ SortableContext
๋ก ๊ฐ์ธ๊ณ , ๋ฆฌ์คํธ์ ๊ฐ ์์ดํ
์ useSortable
ํ
๋ฐํ๊ฐ์ ์ ์ฉํ๋ฉด ๋๋๊ทธ ์ค ๋๋กญ์ผ๋ก ์์ดํ
์์๋ฅผ ๋ณ๊ฒฝํ ์ ์๋ค. ์ฐธ๊ณ ๋ก useSortable
ํ
์ useDraggable
, useDroppable
ํ
์ ๊ฒฐํฉํ์ฌ ๋ง๋ ํ๋ฆฌ์
์ด๋ค.
SortableContext
: ๋ฆฌ์คํธ ์์ ๊ด๋ฆฌuseSortable
: ๋๋๊ทธ ์ค ๋๋กญ ๊ธฐ๋ฅ ๋ถ์ฌ, ์์ดํ ์์น ๋ณํ์ ํ์ํ ์ํ์ ์ด๋ฒคํธ ํธ๋ค๋ฌ ์ ๊ณต.
Task๋ ๋ถ๋ชจ ์ปจํ
์ด๋(์ํด์๋ ์ปฌ๋ผ)๋ฅผ ๋ฒ์ด๋ ๋ค๋ฅธ ์ปฌ๋ผ์ผ๋ก ์ด๋ํ ์ ์์ด์ผ ํ๋ฏ๋ก, ๋๋๊ทธ ์์ดํ
์ DragOverlay
๋ก ๊ฐ์ธ์ผํ๋ค. DragOverlay
๋ ๊ธฐ์กด ๋ฌธ์ ํ๋ฆ์์ ๋ถ๋ฆฌ๋์ด ๋ทฐํฌํธ๋ฅผ ๊ธฐ์ค์ผ๋ก ๋๋๊ทธ ๊ฐ๋ฅํ ์ค๋ฒ๋ ์ด๋ฅผ ๋ ๋๋ง ํ๋ค.
// board.tsx
// ...
import { DndContext, DragOverlay } from '@dnd-kit/core';
import { SortableContext } from '@dnd-kit/sortable';
const Board = () => {
// ...
// onDragStart, onDragEnd ๋ฑ ํธ๋ค๋ฌ ๋ก์ง์ ์ ์ํ ์ปค์คํ
ํ
const { handlers, dragColumnId, dragTaskId, /* ... */ } = useKanbanDnd();
return (
<div className="...">
{/* ๋ฆฌ์คํธ๋ฅผ DndContext, SortableContext๋ก ๊ฐ์ธ์ค๋ค */}
<DndContext {...handlers} id={...} sensors={...} modifiers={...}>
<SortableContext items={board.columnIds} id={board.id}>
{board.columnIds.map((columnId) => (
<Column key={columnId} columnId={columnId} />
))}
</SortableContext>
<DragOverlay>
{/* ๋๋๊ทธ ์ค์ธ ์์ดํ
*/}
{dragColumnId && <Column columnId={toColumnId(dragColumnId)} />}
{dragTaskId && <Task taskId={toTaskId(dragTaskId)} />}
</DragOverlay>
</DndContext>
</div>
);
};
๋๋๊ทธ๋ฅผ ์์ํ ๋ ํ์ฌ ์์๊ฐ ์ปฌ๋ผ์ธ์ง ํ์คํฌ์ธ์ง ํ๋ณํ ํ, DragOverlay
์์ ์กฐ๊ฑด๋ถ ๋ ๋๋งํด์ผ ํ๋ค. ์ด๋ฅผ ์ํด onDragStart
ํธ๋ค๋ฌ์์ ๊ฐ ํ์
(task, column)์ ํด๋นํ๋ id
๋ฅผ ๋ณ๋ ์ํ๋ก ๊ด๋ฆฌํ๋ค.
// use-drag-state.ts
const [dragColumnId, setDragColumnId] = useState<T>();
const [dragTaskId, setDragTaskId] = useState<T>();
const setDragState = useCallback((type: 'task' | 'column', value: T) => {
const setStateMap = { task: setDragTaskId, column: setDragColumnId };
setStateMap[type](value);
}, []);
// use-kanban-dnd.ts
const onDragStart = ({ active }: DragStartEvent) => {
const dragType = getDragTypes(active).isActiveTask ? 'task' : 'column';
setDragState(dragType, active.id);
};
๋ฆฌ์คํธ ์์ดํ
์์ useSortable
ํ
์ด ๋ฐํํ๋ ๊ฐ๋ค์ ๋๋๊ทธ ๋์ ์์์ ์ ์ฉํ๋ค. useSortable
ํ
์ด ๋ฐ๋ id
, data
๋ฑ ์ธ์๋ ์ด๋ฒคํธ ํธ๋ค๋ฌ๋ก ์ ๋ฌ๋๋ค(active, over ์์ฑ์ ์ถ๊ฐ๋จ).
์ ์ฒด ์์๊ฐ ์๋ ํน์ ๋ถ๋ถ์ ํด๋ฆญํ์ ๋๋ง ๋๋๊ทธ๋๋๋ก ํ๊ณ ์ถ๋ค๋ฉด listeners
ํธ๋ค๋ฌ๋ฅผ ๋ค๋ฅธ ์์์ ํ ๋นํ๋ฉด ๋๋ค. ๊ธฐ๋ณธ์ ์ผ๋ก ๋๋กญ ์ง์ ์ ๋๋๊ทธ ๋์ ์์๊ฐ ๊ทธ๋๋ก ๋ ๋๋ง ๋๋ค. ํ
๋๋ฆฌ๋ง ๋ณด์ด๊ฑฐ๋ ํน์ ์คํ์ผ์ ์ ์ฉํ๊ณ ์ถ๋ค๋ฉด isDragging
์ํ๋ฅผ ํ์ฉํด์ ์กฐ๊ฑด๋ถ ๋ ๋๋ง์ผ๋ก ์ฒ๋ฆฌํ๋ฉด ๋๋ค.
// column.tsx
// ...
import { CSS } from '@dnd-kit/utilities';
import { useSortable } from '@dnd-kit/sortable';
const Column = ({ boardId, children }) => {
const {
attributes, // ์ ๊ทผ์ฑ์ ์ํ ARIA ์์ฑ (๋๋๊ทธ ์์์ ์ ์ฉ)
listeners, // ๋๋๊ทธ ์์์ ๊ฐ์งํ๋ ์ด๋ฒคํธ ํธ๋ค๋ฌ
setNodeRef, // ์์๋ฅผ ๋๋๊ทธ ๋์์ผ๋ก ์ค์ ํ๋ ref ํจ์
transform, // ๋๋๊ทธ ์์์ ์์น ๋ณํ๋ฅผ ๋ํ๋ด๋ ๊ฐ (x, y, scaleX, scaleY)
transition, // ๋๋๊ทธ ์์์ ์ ๋๋ฉ์ด์
ํจ๊ณผ๋ฅผ ์ ์ฉํ๋ ๊ฐ
isDragging, // ํ์ฌ ์์๊ฐ ๋๋๊ทธ ์ค์ธ์ง ์ฌ๋ถ boolean
} = useSortable({
id: boardId, // ์์ดํ
์ ๊ณ ์ ID
data: { type: 'column' } // ์ ๋ฌํ ๋ฐ์ดํฐ
});
const style: CSSProperties = {
transform: CSS.Transform.toString(transform), // ์์น ์ด๋ ์คํ์ผ ์ ์ฉ
transition, // ์ ๋๋ฉ์ด์
ํจ๊ณผ ์ ์ฉ
};
if (isDragging) {
// ๋๋๊ทธ ์ค์ผ ๋ ๋๋กญ ์์ญ์ ํ์ํ Placeholder
return <DropPlaceholder variant={type} style={style} ref={setNodeRef} />;
}
return (
<div ref={setNodeRef} style={style} {...listeners} {...attributes}>
{children}
</div>
);
};
๐ก ๋ด๋ถ์์ useSortable
ํ
์ ํธ์ถํ๋ Draggable ๊ณตํต ์ปดํฌ๋ํธ๋ฅผ ๋ง๋ค์ด์ ์ฌ์ฉํ๋ฉด ์ฝ๋๊ฐ ๊น๋ํด์ง๋ค.
์ปฌ๋ผ ์์ ๋ณ๊ฒฝ
์ปฌ๋ผ์ ์ปจํ
์ด๋๋ ํญ์ Board์ด๋ฏ๋ก ์์ดํ
์ ๋๋กญํ์ ๋ ํธ์ถ๋๋ onDragEnd
ํธ๋ค๋ฌ์์ ์ฒ๋ฆฌํ ์ ์๋ค. ํธ๋ค๋ฌ์ ์ธ์๋ก ์ ๋ฌ๋๋ active
๋ ๋๋๊ทธ ์ค์ธ ์์ดํ
์, over
๋ ๋๋กญ ์์น์ ์ปจํ
์ด๋๋ฅผ ์ฐธ์กฐํ๋ค.
// use-kanban-dnd.ts
const onDragEnd = ({ active, over }: DragEndEvent) => {
// ๋๋๊ทธ ์ค์ธ ์์ดํ
์ด DragOverlay์์ ๋ ๋๋ง ๋์ง ์๋๋ก dragColumnId, dragTaskId ์ด๊ธฐํ
// ํ๋จ์ early return ์์ผ๋ฏ๋ก ์ต์๋จ์์ ์ด๊ธฐํ
resetDragState();
if (!over) return; // ๋๋กญ ์์ญ ๋ฒ์ด๋ฌ์ ๋
if (active.id === over?.id) return; // ๊ฐ์ ์์น๋ ์คํต
if (!getDragTypes(active).isActiveColumn) return; // Column ๋๋๊ทธ๊ฐ ์๋๋ฉด ์คํต
const activeSort = active.data.current?.sortable as ColumnSortable;
const overSort = over?.data.current?.sortable as ColumnSortable;
// arrayMove๋ dnd-kit ์์ฒด์ ์ผ๋ก ์ ๊ณตํ๋ ํฌํผ ํจ์
const newColumnIds = arrayMove(activeSort.items, activeSort.index, overSort.index);
moveColumn(activeSort.containerId, newColumnIds);
};
Sortable ํ๋ฆฌ์
์ ์ฌ์ฉํ๋ฉด active
, over
๊ฐ์ฒด์ sortable
์ด๋ผ๋ ์ ์ฉํ ์์ฑ์ด ์ถ๊ฐ๋๋ค. ์ด ์์ฑ์ ํ์ฉํ๋ฉด ๋๋๊ทธ ์ค์ธ ์์ดํ
์ด ์ํ ์ปจํ
์ด๋ ID, ์ธ๋ฑ์ค, ์ปจํ
์ด๋ ๋ด์ ์ ์ฒด ์์ดํ
๋ชฉ๋ก์ ๋ฐ๋ก ์ฐธ์กฐํ ์ ์์ด์ ์ฝ๋๋ฅผ ๋ ๊ฐ๊ฒฐํ๊ฒ ์์ฑํ ์ ์๋ค.
// active
{
"id": "Column-rYZJ5DsW8WyWUrIZ-tN9p", // ํ์ฌ ์์ดํ
id (useSortable ์ธ์๋ก ๋๊ฒผ๋ id)
"data": {
"current": {
"sortable": {
// ํ์ฌ ์์ดํ
์ด ์ํ ์ปจํ
์ด๋ ID (SortableContext์ ๋๊ฒผ๋ id)
"containerId": "Board-q0tzC2fGuBReTjYuzdUHL",
// ์ปจํ
์ด๋ ๋ด์์ ํ์ฌ ์์ดํ
์ ์ธ๋ฑ์ค
"index": 0,
// ์ปจํ
์ด๋์ ์ ์ฒด ์์ดํ
๋ชฉ๋ก (SortableContext์ ๋๊ฒผ๋ items)
"items": [
"Column-rYZJ5DsW8WyWUrIZ-tN9p", // ํ์ฌ ์์ดํ
"Column-uh6jGsYnCfu3peUX5ZF-O"
]
},
// ...
"type": "column" // ํ์ฌ ์์ดํ
type (useSortable ์ธ์๋ก ๋๊ฒผ๋ type)
}
},
// ...
}
๐ก dnd-kit ๋ผ์ด๋ธ๋ฌ๋ฆฌ ์์ฒด์ ์ผ๋ก arraySwap
, arrayMove
์ ํธ๋ฆฌํฐ ํจ์๋ฅผ ์ ๊ณตํ๋ค. arraySwap
์ ๋ฐฐ์ด ๋ด ๋ ์์์ ์์น๋ฅผ ๊ตํํ๊ณ , arrayMove
๋ ์ง์ ํ ์ธ๋ฑ์ค๋ก ์์๋ฅผ ์ด๋์ํจ ํ ๋๋จธ์ง ์์๋ฅผ ๋ฐ์ด๋ธ๋ค.
import { arraySwap, arrayMove } from '@dnd-kit/sortable';
const items = ['A', 'B', 'C', 'D'];
const swapped = arraySwap(items.slice(), 1, 3); // ์ธ๋ฑ์ค 1(B)์ 3(D) ๊ตํ
const moved = arrayMove(items.slice(), 1, 3); // ์ธ๋ฑ์ค 1(B)์ ์ธ๋ฑ์ค 3 ์์น๋ก ์ด๋
console.log(swapped); // ['A', 'D', 'C', 'B']
console.log(moved); // ['A', 'C', 'D', 'B']
ํ์คํฌ ์์ ๋ณ๊ฒฝ / ์ปฌ๋ผ ๊ฐ ์ด๋
์ปฌ๋ผ์ ๋๋๋๋ ํ์คํฌ ์ด๋์ onDragEnd
์ด๋ฒคํธ ํธ๋ค๋ฌ์์ ์ฒ๋ฆฌํ ์ ์๋ค. ์๋ฅผ ๋ค์ด A ์ปฌ๋ผ(์ปจํ
์ด๋)์ ์๋ ํ์คํฌ๋ฅผ B ์ปฌ๋ผ์ผ๋ก ๋๋๊ทธํ๋ฉด, ๋๋กญํด์ onDragEnd
ํธ๋ค๋ฌ๊ฐ ํธ์ถ๋๊ธฐ ์ ๊น์ง ์ด๋ ์ปฌ๋ผ์ผ๋ก ์ด๋ํ๋์ง ๊ฐ์งํ ์ ์๋ค. ๋๋ฌธ์ ๋๋กญ ์์ญ์ด ๋ค๋ฅธ ์ปจํ
์ด๋๋ผ๋ฉด Placeholder UI๊ฐ ๋ ๋๋ง ๋์ง ์๋๋ค.
UI๋ฅผ ์ฆ์ ๋ฐ์ํ๋ ค๋ฉด ํ์คํฌ๋ฅผ ์ปฌ๋ผ ์์ญ์ผ๋ก ๋๋๊ทธํ ๋๋ง๋ค, ํด๋น ์ปฌ๋ผ์ ์์ดํ
๋ชฉ๋ก์ ์
๋ฐ์ดํธํด์ผ ํ๋ค. ์ด๋ฅผ ์ํด ๋๋๊ทธ ์ค์ธ ์์ดํ
์ด ๋๋กญ ์ปจํ
์ด๋ ์๋ก ์ด๋ํ ๋๋ง๋ค ํธ์ถ๋๋ onDragOver
์ด๋ฒคํธ๋ฅผ ํ์ฉํ๋ค.
// use-kanban-dnd.ts
const onDragOver = ({ active, over, delta, activatorEvent }: DragOverEvent) => {
if (!over) return; // ๋๋กญ ์์ญ ๋ฒ์ด๋ฌ์ ๋
if (active.id === over.id) return; // ๊ฐ์ ์์น๋ ์คํต
const { isActiveTask, isOverTask, isOverColumn } = getDragTypes(active, over);
if (!isActiveTask) return; // Task ๋๋๊ทธ๊ฐ ์๋๋ฉด ์คํต
const activeSort = active.data.current?.sortable as TaskSortable;
const overSort = over.data.current?.sortable as TaskSortable;
const sourceTaskId = toTaskId(active.id);
const sourceTaskIdx = activeSort.index; // ๋๋๊ทธ๋ฅผ ์์ํ ์นด๋์ ์ธ๋ฑ์ค
const sourceColumnId = activeSort.containerId;
// ๋๋กญ ์์ญ์ด Task ์นด๋์ด๋ฉด ํด๋น ์นด๋์ ์ปจํ
์ด๋๋ ์ปฌ๋ผ์ด๋ฏ๋ก overSort.containerId ์์ ID ํ๋
// ๋๋กญ ์์ญ์ด ์ปฌ๋ผ์ด๋ฉด over ์์ฒด๋ ์ปฌ๋ผ์ ์ฐธ์กฐํ๋ฏ๋ก over.id ์์ ID ํ๋
const targetColumnId = isOverTask ? overSort.containerId : toColumnId(over.id);
const targetColumn = columns[targetColumnId];
// ๋๋๊ทธ ์์ ์์น(clientY)์ ์ด๋ ๊ฑฐ๋ฆฌ(delta.y)๋ฅผ ํฉ์ฐํด์ ํ์ฌ Y ์์น ๊ณ์ฐ
const currentY = (activatorEvent as MouseEvent).clientY + delta.y;
// ๋๋กญ ๋์ ์นด๋์ ์ธ๋ฑ์ค (computeTargetTaskIdx ํจ์ ์ค๋ช
์ ์๋ ๋ด์ฉ ์ฐธ๊ณ )
const targetTaskIdx = computeTargetTaskIdx({
isOverColumn,
targetColumn,
overSort,
sourceTaskId,
currentY,
});
moveTask({
sourceTaskId,
sourceColumnId,
targetColumnId,
sourceTaskIdx,
targetTaskIdx,
});
};
ํ์คํฌ๋ฅผ ๋๋๊ทธํ์ ๋ ๋๋กญ ์ง์ ์ โถ"์ปฌ๋ผ ์์ญ ์"(์ปฌ๋ผ์ ํ์คํฌ๊ฐ ์๊ฑฐ๋ ์ปฌ๋ผ๋ด ๋ค๋ฅธ ๊ณต๊ฐ์ ์์น), โท"ํด๋น ์ปฌ๋ผ์ ๋ค๋ฅธ ํ์คํฌ ์" ์ด๋ ๊ฒ ๋ ๊ฐ์ง ๊ฒฝ์ฐ๋ก ๋๋๋ฉฐ, ๊ฐ ์ํฉ์ ๋ฐ๋ผ ์ฒ๋ฆฌ ๋ฐฉ์์ด ๋ฌ๋ผ์ง๋ค. ๋ํ ํ์คํฌ๋ฅผ ๋๋๊ทธํ ๋๋ง๋ค ์๋ 3๊ฐ์ง ์ํ๋ฅผ ์ ๋ฐ์ดํธํด์ผ ํ๋ค.
- ๋๋๊ทธ ์์ดํ
์
task.columnId
(๋ณ๊ฒฝ๋ ์ปฌ๋ผ ID๋ก ๊ต์ฒด) - ๋๋๊ทธ ์ค์ธ ์์ดํ
์ด ์ํ๋ ์ปฌ๋ผ์
column.taskIds
- ๋์ผ ์ปฌ๋ผ ๋ด์์ ๋๋๊ทธํ๋ค๋ฉด ์ธ๋ฑ์ค ์์๋ง ๋ณ๊ฒฝ
- ๋ค๋ฅธ ์ปฌ๋ผ์ผ๋ก ๋๋๊ทธํ๋ค๋ฉด ํด๋น ํ์คํฌ ID ์ ๊ฑฐ
- ๋๋กญ ๋์ ์ปฌ๋ผ์
column.taskIds
(์ธ๋ฑ์ค ์์ ๋ณ๊ฒฝ)
์ ์ํ๋ฅผ ์ ๋ฐ์ดํธํ๊ธฐ ์ํด์ ์์ค ์ปฌ๋ผ ID, ํ๊ฒ ์ปฌ๋ผ ID, ๋๋๊ทธ ์์ดํ (ํ์คํฌ) ID, ์ปฌ๋ผ ๋ด์์ ์์๋ฅผ ๋ณ๊ฒฝํ ๋ ์์ดํ (์์ค/ํ๊ฒ ํ์คํฌ)์ ์ธ๋ฑ์ค ์ ๋ณด๋ฅผ ํ์ธํด์ผ ํ๋ค.
์ปฌ๋ผ ID ํ์ธ
- ์์ค ์ปฌ๋ผ ID:
active
๊ฐ์ฒด์sortable.containerId
- ํ๊ฒ ์ปฌ๋ผ ID:
- ๋๋กญ ๋์ - ํ์คํฌ:
over
๊ฐ์ฒด์sortable.containerId
(ํ์คํฌ์ ์ปจํ ์ด๋๋ ์ปฌ๋ผ์ด๋ฏ๋ก) - ๋๋กญ ๋์ - ์ปฌ๋ผ:
over.id
(์ด๋over
๊ฐ์ฒด๋ ์ปฌ๋ผ์ ์ฐธ์กฐํ๋ฏ๋ก)
- ๋๋กญ ๋์ - ํ์คํฌ:
์์ดํ ์ธ๋ฑ์ค ํ์ธ
- ์์ค ํ์คํฌ(๋๋๊ทธ ์์ดํ
):
active
๊ฐ์ฒดsortable.index
- ํ๊ฒ ํ์คํฌ(๋๋กญ ์์ญ์ ์์นํ ์์ดํ
)
- ๋๋กญ ๋์์ด ํ์คํฌ์ผ ๋:
over
๊ฐ์ฒดsortable.index
- ๋๋กญ ๋์์ด ์ปฌ๋ผ ์์ญ์ผ ๋ (์ปฌ๋ผ์ ์นด๋๊ฐ ์๊ฑฐ๋, ์ปฌ๋ผ ์/์๋์ชฝ ์์น)
- [์ฒซ ์ง์ ์ด ์๋ ๋] ์ปฌ๋ผ.์์ดํ ๋ชฉ๋ก์ ๋๋๊ทธ ์์ดํ ID ๆ → ์กฐํํ ์ธ๋ฑ์ค ๋ฐํ
- [์ฒซ ์ง์
์ผ ๋] ์ปฌ๋ผ.์์ดํ
๋ชฉ๋ก์ ๋๋๊ทธ ์์ดํ
ID ็ก
- ๋์ ์ปฌ๋ผ์ ์ฒซ ๋ฒ์งธ ํ์คํฌ๋ณด๋ค ์์ชฝ์ผ๋ก ๋๋๊ทธํ์ ๋: ์ฒซ ๋ฒ์งธ ์์น (์ธ๋ฑ์ค =
0
) - ๊ทธ ์ธ ์ํฉ: ๋ง์ง๋ง ์์น (์ธ๋ฑ์ค =
taskIds.length
)
- ๋์ ์ปฌ๋ผ์ ์ฒซ ๋ฒ์งธ ํ์คํฌ๋ณด๋ค ์์ชฝ์ผ๋ก ๋๋๊ทธํ์ ๋: ์ฒซ ๋ฒ์งธ ์์น (์ธ๋ฑ์ค =
- ๋๋กญ ๋์์ด ํ์คํฌ์ผ ๋:
์๋๋ ํ๊ฒ ํ์คํฌ์ ์ธ๋ฑ์ค ํ์ธ ๊ณผ์ ์ ์๊ฐํํ ํ๋ก์ฐ์ฐจํธ
๋๋๊ทธ ์์ดํ Y ์ขํ ๊ณ์ฐ
onDragOver
ํธ๋ค๋ฌ๋ delta
, activatorEvent
๊ฐ์ฒด๋ฅผ ์ธ์๋ก ๋ฐ๋๋ค. activatorEvent.clientY
๋ ๋๋๊ทธ๋ฅผ ์์ํ์ ๋ y ์ขํ๋ฅผ ๋ํ๋ด๊ณ , delta.y
๋ ์ด๋ํ ๊ฑฐ๋ฆฌ๋ฅผ ๋ํ๋ธ๋ค. ์ด ๋ ๊ฐ์ ๋ํ๋ฉด ํ์ฌ ๋๋๊ทธ ์ค์ธ ์์ดํ
์ y ์ขํ๋ฅผ ๊ณ์ฐํ ์ ์๋ค.
์ ์ด๋ฏธ์ง์ Top Boundary๋ ์ฒซ ๋ฒ์งธ ํ์คํฌ ์์ชฝ์ ๊ฒฝ๊ณ๋ฅผ ์๋ฏธํ๋ค. ๋ง์ฝ ๊ณ์ฐํ y ์ขํ๊ฐ ์ด ๊ฒฝ๊ณ๋ณด๋ค ์์ผ๋ฉด(Drag Position 1) ๋๋๊ทธ ์์ดํ ์ ์ฒซ ๋ฒ์งธ ์์ดํ ์ผ๋ก ์์น์ํค๊ณ (์ธ๋ฑ์ค = 0), ๊ทธ ์ธ์(Drag Position 2) ๋ง์ง๋ง ์์ดํ (taskIds.length)์ผ๋ก ์์น์ํจ๋ค.
// kanban-utils.ts
export const computeTargetTaskIdx = ({
isOverColumn,
targetColumn,
overSort,
sourceTaskId,
currentY,
topBoundary = 200,
}: ComputeTargetTaskIdxParams): number => {
// ๋๋กญ ๋์์ด ํ์คํฌ์ผ ๋
if (!isOverColumn) return overSort.index;
// ๋๋กญ ๋์์ด ์ปฌ๋ผ ์์ญ์ผ ๋ (์ปฌ๋ผ์ ์นด๋๊ฐ ์๊ฑฐ๋ ์ปฌ๋ผ ์/์๋์ชฝ ์์น)
const index = targetColumn.taskIds.indexOf(sourceTaskId);
// ์ปฌ๋ผ ์์ญ ์ง์
→ ์ฒซ๋ฒ์งธ/๋ง์ง๋ง ์์น์ ์ธ๋ฑ์ค๋ก ๋์ ์ปฌ๋ผ์ ์์ดํ
๋ชฉ๋ก์ ์
๋ฐ์ดํธํ ์ํ์์ ๋ค์ ์์ง์์ ๋
if (index !== -1) return index;
// ๋์ ์ปฌ๋ผ์ ์ฒซ๋ฒ์งธ ์นด๋ ์์น๋ณด๋ค ์๋ก ๋๋๊ทธ ํ์ ๋ ์ฒซ๋ฒ์งธ๋ก, ๊ทธ ์ธ์ ๋ง์ง๋ง ์ธ๋ฑ์ค๋ก ์ค์
return currentY < topBoundary ? 0 : targetColumn.taskIds.length;
};
์ผ์ ์กฐ์
- ํจ๋(Panning): ํ๋ฉด์ ํฐ์นํ ์ํ์์ ๋๋๊ทธํ์ฌ ์ด๋ํ๋ ์ ์ค์ฒ
- ํ์น์ค(Pinch Zoom): ๋ ์๊ฐ๋ฝ์ ๋ชจ์ผ๊ฑฐ๋ ๋ฒ๋ ค์ ํ๋ฉด์ ์ถ์/ํ๋ํ๋ ์ ์ค์ฒ
dnd-kit์ ์ผ์(sensor)๋ ๋๋๊ทธ ์ค ๋๋กญ ์ํธ์์ฉ์ ๊ฐ์งํ๊ณ ์ ์ดํ๋ ์ถ์ํ ๋ ์ด์ด๋ค. ๊ธฐ๋ณธ์ ์ผ๋ก Pointer, Keyboard ์ผ์๊ฐ ํ์ฑํ๋๋ฉฐ, useSensors
ํ
์ ํตํด ๋ค๋ฅธ ์ผ์๋ก ๋ณ๊ฒฝํ ์ ์๋ค. delay
, distance
๊ฐ์ ์ ์ฝ ์กฐ๊ฑด์ ์ถ๊ฐํด์ ๋๋๊ทธ ํ์ฑ์ ๋ฐฉ์งํ ์๋ ์๋ค.
๋ง์ฝ ๋๋๊ทธ ํธ๋ค(listeners๊ฐ ํ ๋น๋์ด ์๋ ์์) ์์ ๋ฒํผ์ด ์๋ค๋ฉด, ํด๋ฆญํ๋ ์๊ฐ ๋๋๊ทธ๊ฐ ํ์ฑํ๋ผ์ ๋ฒํผ ํด๋ฆญ์ด ๋ถ๊ฐ๋ฅํด์ง๋ค. distance
์ ์ฝ ์กฐ๊ฑด์ ์ถ๊ฐํ๋ฉด ์ด๋ฌํ ๋ฌธ์ ๋ฅผ ํด๊ฒฐํ ์ ์๋ค.
๋ชจ๋ฐ์ผ์ Touch ์ผ์์ delay
, tolerance
์ ์ฝ ์กฐ๊ฑด์ ์ถ๊ฐํด์ ์ผ์ ์๊ฐ ์ด์ ํฐ์นํด์ผ๋ง ๋๋๊ทธ๊ฐ ํ์ฑํ๋๋๋ก ํ ์ ์๋ค. ์ผ๋ฐ์ ์ธ ๋กฑํ๋ ์ค ๋๊ธฐ ์๊ฐ์ 250ms๊ฐ ์ ๋นํ๋ค.
Pointer ์ผ์๋ ๋ฐ์คํฌํฑ ํด๋ฆญ๊ณผ ๋ชจ๋ฐ์ผ ํฐ์น ์ด๋ฒคํธ๋ ํจ๊ป ์ฒ๋ฆฌํ๊ธฐ ๋๋ฌธ์ ๋ฐ์คํฌํฑ, ๋ชจ๋ฐ์ผ ๋ค๋ฅธ ์ ์ฝ ์กฐ๊ฑด์ ์ ์ฉํ๋ ค๋ฉด Mouse, Touch ์ผ์๋ฅผ ๊ฐ๊ฐ ์ฌ์ฉํด์ผ ํ๋ค.
// ๋ฐ์คํฌํฑ ์ต์ ํ
const mouseSensor = useSensor(MouseSensor, {
activationConstraint: {
// ๋๋๊ทธ ์์์ ์ํด ์์ ํด๋ฆญ ํ ์ปค์๋ฅผ ์ด๋์์ผ์ผ ํ๋ ์ต์ ๊ฑฐ๋ฆฌ(px)
distance: 10, // ํด๋ฆญ ํ 10px ์ด์ ์์ง์ฌ์ผ ๋๋๊ทธ ์์ (์๋์น ์์ ํด๋ฆญ ๋ฐฉ์ง)
},
});
// ๋ชจ๋ฐ์ผ ์ต์ ํ
const touchSensor = useSensor(TouchSensor, {
activationConstraint: {
// ๋๋๊ทธ ์์์ ์ํด ํฐ์น๋ฅผ ์ ์งํด์ผ ํ๋ ์ต์ ์๊ฐ(ms)
delay: 250, // 250ms ํฐ์น ์ ์ง ํ์ (์ผ๋ฐ์ ์ธ ๋ชจ๋ฐ์ผ ์ฑ์ ๋กฑํ๋ ์ค ๋๊ธฐ ์๊ฐ)
// delay ๋์ ํ์ฉ๋๋ ์ต๋ ์ด๋ ๊ฑฐ๋ฆฌ(px). ์ด๊ณผ์ ๋๋๊ทธ ์ทจ์๋จ.
tolerance: 5, // 5px ์ด๋ด ์์ง์ ํ์ฉ (์๋จ๋ฆผ์ด๋ ๋ฏธ์ธํ ์์ง์ ํ์ฉ)
},
});
const sensors = useSensors(touchSensor, mouseSensor);
return <DndContext sensors={sensors} >
touch-action
CSS ์์ฑ์ ํฐ์น ๊ธฐ๋ฐ ์
๋ ฅ ์ฅ์น์์ ํน์ ์์๊ฐ ์ด๋ค ๊ธฐ๋ณธ ํฐ์น ๋์(ํจ๋, ํ์น์ค ๋ฑ)์ ์ํํ ์ง ๊ฒฐ์ ํ๋ค. ํฐ์น ์ผ์๋ฅผ ์ฌ์ฉํ๋ค๋ฉด touch-action: manipulation
์ผ๋ก ์ค์ ํ๋ ๊ฒ์ ๊ถ์ฅํ๊ณ ์๋ค.
.draggable-item {
/* ํจ๋/ํ์น์ค์ ํ์ฉํ์ง๋ง ๋๋ธํญ ํ๋ ๊ฐ์ ๋นํ์ค ์ ์ค์ฒ๋ ๋นํ์ฑํํ์ฌ ํฐ์น ๋๋๊ทธ ๋ฐ์์ฑ ๊ฐ์ */
touch-action: manipulation;
/* ํ
์คํธ ์ ํ ๋ฐฉ์ง */
user-select: none;
/* ํ
์คํธ ์ ํ ๋ฐฉ์ง for iOS Safari */
-webkit-user-select: none;
/* ํ
์คํธ ์ ํ ๋ฐฉ์ง for Firefox */
-moz-user-select: none;
}
์ฐธ๊ณ ๋ก Pointer ์ผ์๋ฅผ ์ฌ์ฉํ๋ฉด ๋ชจ๋ฐ์ผ ํ๊ฒฝ์์ ๋๋๊ทธํ ๋ ๋ธ๋ผ์ฐ์ ๊ธฐ๋ณธ ๋์์ผ๋ก ์ธํด ํ๋ฉด๋ ํจ๊ป ์คํฌ๋กค๋๋ ๋ฌธ์ ๊ฐ ๋ฐ์ํ๋ค. ํด๊ฒฐํ๋ ค๋ฉด ๋๋๊ทธํ ์์์ touch-action: none
์ ์ค์ ํ์ฌ ๋ธ๋ผ์ฐ์ ์ ๊ธฐ๋ณธ ํฐ์น ๋์์ ๋นํ์ฑํํด์ผ ํ๋ค. (์ฐธ๊ณ ๋งํฌ)
์ด์ ํด๊ฒฐ
ํ์ด๋๋ ์ด์ ์๋ฌ
Next.js๋ฅผ ์ฌ์ฉํ๋ค๋ฉด Warning: Prop aria-describedby did not match… ๊ฐ์ ํ์ด๋๋ ์ด์
์๋ฌ๊ฐ ๋ฐ์ํ ์ ์๋ค. ์๋ฒ ์ฌ์ด๋ ๋ ๋๋ง ์ ์์ฑํ DndContext ID์ ํด๋ผ์ด์ธํธ์์ ์์ฑํ ID๊ฐ ์ผ์นํ์ง ์์์ ๋ฐ์ํ๋ ๋ฌธ์ . ๋ฆฌ์กํธ useId
ํ
์ ์ด์ฉํ์ฌ ์ผ๊ด๋ ID๋ฅผ ์์ฑํ๋ฉด ํด๊ฒฐํ ์ ์๋ค. (์ฐธ๊ณ ์ด์ #926)
import { useId } from 'react';
const id = useId()
return <DndContext id={id} />
๋ฌดํ ๋ฃจํ
์์ดํ
์ ์ฌ๊ธฐ์ ๊ธฐ ๋๋๊ทธํ๋ค ๋ณด๋ฉด ๊ฐ๋ Maximum update depth exceeded(์ปดํฌ๋ํธ ๋ฌดํ ๋ฃจํ) ์๋ฌ๊ฐ ๋ฐ์ํ๋ค. ์ด ์๋ฌ๋ Sortable ํ๋ฆฌ์
์ ์ฌ์ฉํ ๋ ๋ฐ์ํ๋ค. onDragOver
ํธ๋ค๋ฌ์ ์๋ ์ํ ๋ณ๊ฒฝ ํจ์์ 0ms ๋๋ฐ์ด์ค๋ฅผ ์ ์ฉํ๋ฉด ํด๊ฒฐํ ์ ์๋ค. (์ฐธ๊ณ ์ด์ #900)
// use-kanban-dnd.ts
import { useDebouncedCallback } from 'use-debounce';
const moveTask = useKanbanStore.use.moveTask();
const debouncedMoveTask = useDebouncedCallback(moveTask, 0);
// ...
const onDragOver = ({ active, over, delta, activatorEvent }: DragOverEvent) => {
// ...
debouncedMoveTask({
sourceTaskId,
sourceColumnId,
targetColumnId,
sourceTaskIdx,
targetTaskIdx,
});
};
๊ธ ์์ ์ฌํญ์ ๋ ธ์ ํ์ด์ง์ ๊ฐ์ฅ ๋น ๋ฅด๊ฒ ๋ฐ์๋ฉ๋๋ค. ๋งํฌ๋ฅผ ์ฐธ๊ณ ํด ์ฃผ์ธ์
'๐ช Programming' ์นดํ ๊ณ ๋ฆฌ์ ๋ค๋ฅธ ๊ธ
[UI] Shadcn DropdownMenu์์ Dialog ์๋ ๋ซํ ๋ฌธ์ ํด๊ฒฐ (0) | 2025.03.09 |
---|---|
[CSS] :focus, :focus-visible ์ฐจ์ด์ (1) | 2025.03.06 |
[JS] ์๋ฐ์คํฌ๋ฆฝํธ ๋๋ค ์์(Random Color) ์์ฑํ๊ธฐ (0) | 2025.03.04 |
[CSS] ์์ ๋งฅ๋ฝ Stacking Context (0) | 2025.02.26 |
[React] ๋ฆฌ์กํธ 19 ์ ๋ฐ์ดํธ ๋ด์ฉ ํบ์๋ณด๊ธฐ (0) | 2025.02.08 |
๋๊ธ
์ด ๊ธ ๊ณต์ ํ๊ธฐ
-
๊ตฌ๋
ํ๊ธฐ
๊ตฌ๋ ํ๊ธฐ
-
์นด์นด์คํก
์นด์นด์คํก
-
๋ผ์ธ
๋ผ์ธ
-
ํธ์ํฐ
ํธ์ํฐ
-
Facebook
Facebook
-
์นด์นด์ค์คํ ๋ฆฌ
์นด์นด์ค์คํ ๋ฆฌ
-
๋ฐด๋
๋ฐด๋
-
๋ค์ด๋ฒ ๋ธ๋ก๊ทธ
๋ค์ด๋ฒ ๋ธ๋ก๊ทธ
-
Pocket
Pocket
-
Evernote
Evernote
๋ค๋ฅธ ๊ธ
-
[UI] Shadcn DropdownMenu์์ Dialog ์๋ ๋ซํ ๋ฌธ์ ํด๊ฒฐ
[UI] Shadcn DropdownMenu์์ Dialog ์๋ ๋ซํ ๋ฌธ์ ํด๊ฒฐ
2025.03.09 -
[CSS] :focus, :focus-visible ์ฐจ์ด์
[CSS] :focus, :focus-visible ์ฐจ์ด์
2025.03.06 -
[JS] ์๋ฐ์คํฌ๋ฆฝํธ ๋๋ค ์์(Random Color) ์์ฑํ๊ธฐ
[JS] ์๋ฐ์คํฌ๋ฆฝํธ ๋๋ค ์์(Random Color) ์์ฑํ๊ธฐ
2025.03.04 -
[CSS] ์์ ๋งฅ๋ฝ Stacking Context
[CSS] ์์ ๋งฅ๋ฝ Stacking Context
2025.02.26