Vue + TypeScriptでカンバン式Todoにドラッグ&ドロップを実装したときに詰まったことと解決
はじめに
Vue 3 + TypeScriptでカンバン形式のTodoアプリを作成し、ドラッグ&ドロップでタスクを移動・並び替えできるようにしました。
実装自体はシンプルに見えますが、実際にはいくつかハマりどころがありました。
この記事では以下をまとめます。
- ドラッグ&ドロップ実装の基本
- 詰まったポイント
- 解決方法
- 実装の設計ポイント
実装した機能
- カラム間移動(Todo → Doing → Done)
- 同一カラム内の並び替え
- 並び順を
positionで管理
結論
ドラッグ&ドロップは「UI」ではなく「状態設計」が重要でした。
1. データ構造
まず、Todoに順序管理用の position を持たせます。
export interface Todo {
id: string
title: string
status: 'todo' | 'doing' | 'done'
position: number
}
これがないと、同一カラム内の並び替えを安定して保存できません。
2. ドラッグ状態の管理
今回の実装では、ドラッグ中の状態を以下のように分けました。
const draggingId = ref<string | null>(null)
const dropTargetStatus = ref<TodoStatus | null>(null)
const dropPreview = ref<DropPreview>(null)
役割は以下です。
-
draggingId:現在ドラッグしているTodo -
dropTargetStatus:現在ホバーしているカラム -
dropPreview:対象カードの前後どちらに落とすか
状態を分けて持つことで、カラム移動と同一カラム内並び替えを同じ流れで扱いやすくなりました。
3. ハマりポイント①:dropできない
最初に詰まったのは、drop イベントが発火しないことでした。
原因は dragover 側で preventDefault() を呼んでいなかったことです。
const onColumnDragOver = (status: TodoStatus, event: DragEvent) => {
if (!draggingId.value) return
event.preventDefault()
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move'
}
dropTargetStatus.value = status
}
HTML5 Drag and Drop APIでは、dragover で event.preventDefault() を呼ばないと、ドロップ対象として扱われません。
4. ハマりポイント②:配列の末尾取得でundefined扱いになる
並び順を計算するために、同じカラム内の最後のTodoを取得しようとしました。
const sameColumn = store.todos
.filter((todo) => todo.status === status && todo.id !== id)
.sort((a, b) => a.position - b.position)
const lastTodo = sameColumn[sameColumn.length - 1]
const nextPosition = lastTodo ? lastTodo.position + 1 : 1
最初は以下のように直接書いていました。
const nextPosition =
sameColumn.length > 0
? sameColumn[sameColumn.length - 1].position + 1
: 1
しかし、TypeScript上では sameColumn[sameColumn.length - 1] が undefined の可能性を持つため、警告が出ました。
一度 lastTodo として変数に受け、存在チェックしてから position を読む形にすると安全です。
5. ハマりポイント③:Array.at() が使えない
末尾取得に array.at(-1) を使おうとすると、環境によっては以下のようなエラーが出ます。
プロパティ 'at' が型 'Todo[]' に存在しません。
'lib' コンパイラ オプションを 'es2022' 以降に変更してみてください。
原因は、TypeScriptの lib 設定が Array.prototype.at() に対応していないことです。
対応方法は2つあります。
-
tsconfig.jsonのlibをES2022以降にする - 従来の index アクセスを使う
今回は設定変更を避け、以下のように書きました。
const lastTodo = sameColumn[sameColumn.length - 1]
const nextPosition = lastTodo ? lastTodo.position + 1 : 1
6. 並び替えの基本方針
同一カラム内の並び替えは、以下の流れで実装しました。
- 移動対象のTodoを配列から除外する
- ドロップ先の位置を計算する
- 対象位置にTodoを挿入する
-
positionを再採番する - storeに保存する
再採番は以下のようにしました。
const renumberTodos = (items: Todo[]) => {
return items.map((todo, index) => ({
...todo,
position: index + 1,
}))
}
この処理を挟むことで、削除や移動後に position が飛んだり重複したりする問題を避けられます。
7. カラムをまたぐ移動
カラムをまたぐ場合は、status と position の両方を更新します。
await store.updateTodo(id, {
status,
position: nextPosition,
})
ここで大事なのは、移動元カラムと移動先カラムを分けて考えることです。
同じカラム内であれば並び替えだけで済みますが、別カラムに移動する場合は、
- 移動元カラムの再採番
- 移動先カラムの再採番
- Todoの
status更新
が必要になります。
8. UIの重複問題
最初は、Todo / Doing / Done の3カラムをそれぞれテンプレートに直接書いていました。
この形だと、編集UIや削除ボタンを修正するたびに、3箇所を同じように直す必要があります。
そこで、カラム情報を配列化しました。
const columns = computed<TodoColumn[]>(() => [
{
key: 'todo',
title: 'Todo',
emptyMessage: 'まだタスクはありません。',
items: filteredTodos.value.filter((t) => t.status === 'todo'),
},
{
key: 'doing',
title: 'Doing',
emptyMessage: '進行中のタスクはありません。',
items: filteredTodos.value.filter((t) => t.status === 'doing'),
},
{
key: 'done',
title: 'Done',
emptyMessage: '完了したタスクはありません。',
items: filteredTodos.value.filter((t) => t.status === 'done'),
},
])
テンプレート側は v-for で描画できます。
<section
v-for="column in columns"
:key="column.key"
class="todo-column"
>
...
</section>
これで、カラム追加やUI修正がかなり楽になりました。
9. レイアウト崩れへの対応
実装中、追加フォームの入力欄とボタンが重なる問題がありました。
原因は、入力欄が横幅いっぱいに伸びる一方で、ボタン側の幅がうまく確保できていなかったことです。
対応として、flex ではなく grid に変更しました。
.todo-create {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
align-items: end;
}
.todo-create-field {
min-width: 0;
}
min-width: 0 を指定することで、入力欄が必要以上に横へ広がるのを防げます。
10. コンポーネント分割
機能が増えるにつれて、TodoView.vue が大きくなってきました。
そこで、以下のように分割しました。
src/
├─ components/
│ └─ todo/
│ ├─ TodoCreateForm.vue
│ ├─ TodoColumn.vue
│ └─ TodoItemCard.vue
├─ stores/
│ └─ todos.ts
└─ views/
└─ TodoView.vue
それぞれの責務は以下です。
-
TodoView.vue:状態管理、イベント制御、ドラッグ&ドロップ処理 -
TodoCreateForm.vue:新規タスク追加フォーム -
TodoColumn.vue:カラム表示 -
TodoItemCard.vue:タスク1件分の表示・編集UI -
todos.ts:Piniaによる状態管理とLocalStorage保存
11. イベントemitの型エラー
コンポーネント分割後、イベントの中継でTypeScriptエラーが出ました。
原因は、template内で複数引数イベントをそのまま中継していたことです。
@status-change="$emit('status-change', $event[0], $event[1])"
この書き方だと、Vue + TypeScriptの型推論が崩れやすくなります。
対応として、script側に中継関数を定義しました。
const emitStatusChange = (id: string, event: Event) => {
emit('status-change', id, event)
}
template側では関数を呼びます。
@status-change="emitStatusChange"
この形にすると、引数の型が明確になり、オーバーロード解決が安定しました。
学んだこと
今回一番大きかった学びは、ドラッグ&ドロップは見た目の実装ではなく、状態設計の問題だということです。
特に重要だったのは以下です。
-
positionがないと順序管理は安定しない -
draggingIdやdropPreviewなど、状態を分けて持つと整理しやすい - UIの重複は早めに潰した方がいい
- TypeScriptの警告は、データが存在しない可能性を先に教えてくれる
- コンポーネント分割後は、イベントemitの型設計が重要になる
まとめ
Vue + TypeScriptでドラッグ&ドロップを実装する中で、以下を学びました。
-
dragoverではpreventDefault()が必要 - 並び替えには
positionが必要 - 配列操作後は再採番すると安定する
- UIの重複は
columns配列で解消できる - コンポーネント分割後はイベント型を丁寧に扱う
「とりあえず動く」状態から、「あとで壊れにくい」構造に近づけられたのが今回の収穫でした。
同じようにVueでTodoやカンバンUIを作る人の参考になれば嬉しいです。