vuedraggableの仕組みを理解する

はじめに

要素をドラッグアンドドロップできるVue.jsライブラリ「vuedraggable」の仕組みを理解するために記事を書いていく。

現状の動作

サバ味噌煮を高カカオチョコレートの下に持っていったりすることができる。

before
after

サバ味噌煮をクリックした状態で、高カカオチョコレートの下まで移動して、クリックを手放した。

現状のソース

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>

最小構成

最小構成で理解していこう。

vuedraggableの最小構成を実装

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;
  }

画面(並べ替え前)

画面(並べ替え後)

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です