vuedraggableの仕組みを理解する
はじめに
要素をドラッグアンドドロップできるVue.jsライブラリ「vuedraggable」の仕組みを理解するために記事を書いていく。
現状の動作
サバ味噌煮を高カカオチョコレートの下に持っていったりすることができる。


サバ味噌煮をクリックした状態で、高カカオチョコレートの下まで移動して、クリックを手放した。
現状のソース
frontend/src/components/ShoppingList.vueから、vuedraggableに関連する部分を抜粋する。
<template>
<div>
<form @submit.prevent="createSection" class="row">
<input v-model="newSectionTitle" placeholder="見出し(セクション)を追加" />
<button>追加</button>
</form>
<section v-for="section in sections" :key="section.id" class="section">
<header class="section-header">
<input v-model="section.title" @change="updateSection(section)" />
<button @click="removeSection(section)">削除</button>
</header>
<form @submit.prevent="createItem(section)" class="row">
<input v-model="section._newName" placeholder="商品名" />
<input v-model="section._newQty" placeholder="数量(任意)" />
<button>追加</button>
</form>
<draggable
v-model="section.items"
item-key="id"
@end="onReorder(section)"
class="list"
>
<template #item="{ element: item }">
<div class="item">
<label class="checkbox">
<input type="checkbox" @change="toggleItem(item)" />
</label>
<div class="item-main">
<div class="name">{{ item.name }}</div>
<div class="qty" v-if="item.qty">{{ item.qty }}</div>
</div>
<button class="delete" @click="hardDelete(item)">×</button>
</div>
</template>
</draggable>
</section>
</div>
</template>
<script setup>
import draggable from 'vuedraggable'
const sections = reactive([])
async function createSection() {
if (!offline.value) {
await axios.post('/api/sections/', { list: listId, title, position: sections.length })
await fetchSectionsOnline()
}
}
async function createItem(section) {
if (!offline.value && !(''+section.id).startsWith('tmp-sec-')) {
await axios.post('/api/items/', { section: section.id, name, qty, position: section.items.length })
await fetchSectionsOnline()
}
async function onReorder(section) {
const ids = section.items.map(i => i.id)
saveLocal(sections)
if (!offline.value && !(''+section.id).startsWith('tmp-sec-') && !ids.some(id => (''+id).startsWith('tmp-item-'))) {
await axios.post(`/api/sections/${section.id}/reorder/`, { item_ids: ids })
} else {
pushQueue({ type: 'reorder', payload: { sectionId: section.id, ids } })
}
}
</script>
draggable部分
<draggable
v-model="section.items"
item-key="id"
@end="onReorder(section)"
class="list"
>
<template #item="{ element: item }">
<div class="item">
<label class="checkbox">
<input type="checkbox" @change="toggleItem(item)" />
</label>
<div class="item-main">
<div class="name">{{ item.name }}</div>
<div class="qty" v-if="item.qty">{{ item.qty }}</div>
</div>
<button class="delete" @click="hardDelete(item)">×</button>
</div>
</template>
</draggable>
これが最終的に以下の形にブラウザ出力されている。

listが一番外側にあって、itemが4つ入っている。
<div class="list">
<div class="item" data-draggable="true" draggable="false">
<label class="checkbox">
<input type="checkbox">
</label>
<div class="item-main">
<div class="name">高カカオチョコレート</div>
<!--v-if-->
</div>
<button class="delete">×</button>
</div>
<div class="item" data-draggable="true" draggable="false">
<label class="checkbox">
<input type="checkbox">
</label>
<div class="item-main">
<div class="name">ナッツ</div>
<!--v-if-->
</div>
<button class="delete">×</button>
</div>
<div class="item" data-draggable="true" draggable="false">
<label class="checkbox">
<input type="checkbox">
</label>
<div class="item-main">
<div class="name">サラダチキン</div>
<!--v-if-->
</div>
<button class="delete">×</button>
</div>
<div class="item" data-draggable="true" draggable="false">
<label class="checkbox">
<input type="checkbox">
</label>
<div class="item-main">
<div class="name">サバ味噌煮</div>
<!--v-if-->
</div>
<button class="delete">×</button>
</div>
</div>
v-model=”section.items”
<draggable
v-model="section.items"
item-key="id"
@end="onReorder(section)"
class="list"
>
<template #item="{ element: item }">
<div class="item">
<label class="checkbox">
<input type="checkbox" @change="toggleItem(item)" />
</label>
<div class="item-main">
<div class="name">{{ item.name }}</div>
<div class="qty" v-if="item.qty">{{ item.qty }}</div>
</div>
<button class="delete" @click="hardDelete(item)">×</button>
</div>
</template>
</draggable>
sectionの中のitemsとバインドしている。
section
sectionは外側でsectionsから取り出したもの。
<section v-for="section in sections" :key="section.id" class="section">
<header class="section-header">
<input v-model="section.title" @change="updateSection(section)" />
<button @click="removeSection(section)">削除</button>
</header>
<form @submit.prevent="createItem(section)" class="row">
<input v-model="section._newName" placeholder="商品名" />
<input v-model="section._newQty" placeholder="数量(任意)" />
<button>追加</button>
</form>
<draggable
v-model="section.items"
item-key="id"
@end="onReorder(section)"
class="list"
>
<template #item="{ element: item }">
<div class="item">
<label class="checkbox">
<input type="checkbox" @change="toggleItem(item)" />
</label>
<div class="item-main">
<div class="name">{{ item.name }}</div>
<div class="qty" v-if="item.qty">{{ item.qty }}</div>
</div>
<button class="delete" @click="hardDelete(item)">×</button>
</div>
</template>
</draggable>
</section>
sections
sectionsは、スクリプトでリアクティブ変数として定義されており、
<script setup>
import { onMounted, reactive, ref } from 'vue'
import axios from 'axios'
import draggable from 'vuedraggable'
import { loadLocal, saveLocal, loadQueue, pushQueue, setQueue, clearQueue } from '../storage'
const sections = reactive([])
追加ボタンを押した時に発火するcreateSection関数で、
<template>
<div>
<form @submit.prevent="createSection" class="row">
<input v-model="newSectionTitle" placeholder="見出し(セクション)を追加" />
<button>追加</button>
</form>
sectionsにlistIdとtitleのリストが追加されている。
async function createSection() {
const title = (newSectionTitle.value || '').trim()
if (!title) return
if (!offline.value) {
await axios.post('/api/sections/', { list: listId, title, position: sections.length })
await fetchSectionsOnline()
} else {
// ローカル追加
const tmpId = 'tmp-sec-' + Date.now()
sections.push({ id: tmpId, list: listId, title, position: sections.length, items: [], _newName:'', _newQty:'' })
saveLocal(sections)
pushQueue({ type: 'createSection', payload: { title } })
}
newSectionTitle.value = ''
}
items
商品名を入力してアイテム追加を行うと、createItem関数が発火する。
<form @submit.prevent="createItem(section)" class="row">
<input v-model="section._newName" placeholder="商品名" />
<input v-model="section._newQty" placeholder="数量(任意)" />
<button>追加</button>
</form>
ここで、sectionの中にあるitemsにセクションIDとアイテム名と任意の数量を入れている。
async function createItem(section) {
const name = (section._newName || '').trim(); if (!name) return
const qty = section._newQty || ''
if (!offline.value && !(''+section.id).startsWith('tmp-sec-')) {
await axios.post('/api/items/', { section: section.id, name, qty, position: section.items.length })
await fetchSectionsOnline()
} else {
const tmpId = 'tmp-item-' + Date.now()
section.items.push({ id: tmpId, section: section.id, name, qty, position: section.items.length })
saveLocal(sections)
pushQueue({ type: 'createItem', payload: { sectionId: section.id, name, qty } })
}
section._newName = ''
section._newQty = ''
}
item-key=”id”
<draggable
v-model="section.items"
item-key="id"
@end="onReorder(section)"
class="list"
>
<template #item="{ element: item }">
<div class="item">
<label class="checkbox">
<input type="checkbox" @change="toggleItem(item)" />
</label>
<div class="item-main">
<div class="name">{{ item.name }}</div>
<div class="qty" v-if="item.qty">{{ item.qty }}</div>
</div>
<button class="delete" @click="hardDelete(item)">×</button>
</div>
</template>
</draggable>
item-keyとは、要素を一意に識別するキーのこと。
itemのidによって、一つ一つの商品を一意に識別している。
そのidは、商品を削除する関数にconsole.log(item)を追記することで、

コンソール上で確認できた。
高カカオチョコレートの場合、idは59だった。

@end=”onReorder(section)”
<draggable
v-model="section.items"
item-key="id"
@end="onReorder(section)"
class="list"
>
<template #item="{ element: item }">
<div class="item">
<label class="checkbox">
<input type="checkbox" @change="toggleItem(item)" />
</label>
<div class="item-main">
<div class="name">{{ item.name }}</div>
<div class="qty" v-if="item.qty">{{ item.qty }}</div>
</div>
<button class="delete" @click="hardDelete(item)">×</button>
</div>
</template>
</draggable>
@endは、並べ替えが終了した時のイベント。
onReorder関数が発火する。
スロット : #item=”{ element: item }”
<draggable
v-model="section.items"
item-key="id"
@end="onReorder(section)"
class="list"
>
<template #item="{ element: item }">
<div class="item">
<label class="checkbox">
<input type="checkbox" @change="toggleItem(item)" />
</label>
<div class="item-main">
<div class="name">{{ item.name }}</div>
<div class="qty" v-if="item.qty">{{ item.qty }}</div>
</div>
<button class="delete" @click="hardDelete(item)">×</button>
</div>
</template>
</draggable>
section.itemsの中身がelementとして一つ一つ渡ってくる。
templateの中身で、描画レイアウトを定義している。
elementをitemという名前で使用している。
そのため、section.itemsの要素数分だけ、itemが描画されていた。
<div class="list">
<div class="item" data-draggable="true" draggable="false">
<label class="checkbox">
<input type="checkbox">
</label>
<div class="item-main">
<div class="name">高カカオチョコレート</div>
<!--v-if-->
</div>
<button class="delete">×</button>
</div>
<div class="item" data-draggable="true" draggable="false">
<label class="checkbox">
<input type="checkbox">
</label>
<div class="item-main">
<div class="name">ナッツ</div>
<!--v-if-->
</div>
<button class="delete">×</button>
</div>
<div class="item" data-draggable="true" draggable="false">
<label class="checkbox">
<input type="checkbox">
</label>
<div class="item-main">
<div class="name">サラダチキン</div>
<!--v-if-->
</div>
<button class="delete">×</button>
</div>
<div class="item" data-draggable="true" draggable="false">
<label class="checkbox">
<input type="checkbox">
</label>
<div class="item-main">
<div class="name">サバ味噌煮</div>
<!--v-if-->
</div>
<button class="delete">×</button>
</div>
</div>
最小構成
最小構成で理解していこう。

HTML部分
<!-- 最小構成 -->
<draggable v-model="items" item-key="id" class="list">
<template #item="{ element }">
<div class="list-item">{{ element.name }}</div>
</template>
</draggable>
<!-- 並び替え結果の確認用(任意) -->
<pre>{{ items }}</pre>
スクリプト
import { ref } from 'vue'
import draggable from 'vuedraggable' // これだけでOK(<script setup> は自動登録)
const items = ref([
{ id: 1, name: '🍎 りんご' },
{ id: 2, name: '🍌 バナナ' },
{ id: 3, name: '🍇 ぶどう' },
])
スタイル
/* 見やすさ用の最低限 */
.list { margin: 8px 0; }
.list-item {
padding: 8px 12px;
margin: 6px 0;
background: #f6f6f6;
border: 1px solid #ddd;
border-radius: 8px;
cursor: grab;
}
画面(並べ替え前)

画面(並べ替え後)

コメントを残す