【Vue.js】リストをドラッグ&ドロップで並び替える方法|SortableJSとの連携例

【Vue.js】リストをドラッグ&ドロップで並び替える方法|SortableJSとの連携例 Vue.js

タスクの優先順位や、表示の順番をユーザーが自由に決められるように、リストをドラッグ&ドロップで並び替えたい場面があります。Vueでは、SortableJSをVueで使いやすくしたライブラリ、vuedraggableが定番です。この記事では、Vue 3(<script setup>)を前提に、導入から、キーボードでも操作できる並び替え、保存の失敗への備えまでを整理します。

スポンサーリンク

vuedraggableの導入:Vue 3では@nextを付ける

vuedraggableには、Vue 2用とVue 3用があります。Vue 3用は、@nextを付けてインストールします。付けないと、Vue 2向けの版が入り、記事のコードが動きません。SortableJSは、vuedraggableが内部で使うため、別にインストールする必要はありません。

Shell:Vue 3用のインストール
# Vue 3 用(@next を付ける。付けないと Vue 2 向けの版が入る)
npm i -S vuedraggable@next

Vue 2を使っている場合は、別のリポジトリ(Vue.Draggable)が案内されています。この記事は、Vue 3の版を扱います。

基本の使い方

v-modelに配列を結びつけると、ドラッグで並び替えたときに、配列の順序が更新されます。Vue 3版は、item-keyが必須で、項目の見た目は#itemスロットに書きます。スロットには、element(項目)とindex(番号)が渡されます。item-keyには、項目ごとに固有の値(id)を指定してください。文字列そのものをkeyにすると、同じ文字列があるときに重複します。keyの考え方はv-forで配列やオブジェクトをループして表示する方法をご覧ください。

Vue:基本のドラッグ&ドロップ
<script setup>
import { ref } from 'vue'
import draggable from 'vuedraggable'

// 各項目に、固有の id を持たせる
const tasks = ref([
  { id: 1, title: 'タスクA' },
  { id: 2, title: 'タスクB' },
  { id: 3, title: 'タスクC' }
])
</script>

<template>
  <!-- v-model で配列と結びつける。item-key は必須 -->
  <draggable v-model="tasks" item-key="id" tag="ul">
    <template #item="{ element, index }">
      <li>{{ index + 1 }}. {{ element.title }}</li>
    </template>
  </draggable>
</template>

実用的な完成コード

実際の画面で使うときに、必要になる要素を加えた完成版です。ポイントは次のとおりです。

  • つかみ部分(handle):ドラッグできる範囲を、特定の部分に限定します。項目全体をつかめると、リスト内のボタンやリンクの操作と、ぶつかります。
  • ドラッグ中の見た目(ghost-class):ドロップする位置を示すクラスです。
  • アニメーション:SortableJSのオプションは、propsとして渡せます。animationは、並び替えのアニメーションの長さ(ミリ秒)です。
  • 保存:並びが変わったときに、サーバーへIDの配列を送ります。失敗したら、最後に保存できた並びに戻します。
  • ボタンでの移動:ドラッグができない人のための代替です。下で詳しく説明します。
Vue:保存とキーボード操作に対応した完成版
<script setup>
import { ref, nextTick } from 'vue'
import draggable from 'vuedraggable'

const tasks = ref([
  { id: 1, title: 'タスクA' },
  { id: 2, title: 'タスクB' },
  { id: 3, title: 'タスクC' }
])
const message = ref('')                                   // 読み上げ用のメッセージ
let lastSavedIds = tasks.value.map((task) => task.id)     // 最後に保存できた並び

// 並び順をサーバーに保存する。失敗したら、元の並びに戻す
async function saveOrder() {
  const ids = tasks.value.map((task) => task.id)
  try {
    const res = await fetch('/api/save-order', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ order: ids })
    })
    if (!res.ok) throw new Error(`HTTP ${res.status}`)
    lastSavedIds = ids
  } catch (error) {
    tasks.value = lastSavedIds.map((id) => tasks.value.find((task) => task.id === id))
    message.value = '保存に失敗しました。元の並びに戻しました。'
  }
}

// ドラッグができない人のために、ボタンでも動かせるようにする
async function moveItem(index, delta) {
  const target = index + delta
  if (target < 0 || target >= tasks.value.length) return

  const list = [...tasks.value]
  const [item] = list.splice(index, 1)
  list.splice(target, 0, item)
  tasks.value = list
  message.value = `「${item.title}」を${target + 1}番目に移動しました。`

  // DOM の並びが変わるとフォーカスが外れることがあるので、押したボタンに戻す
  await nextTick()
  document.getElementById(`${delta < 0 ? 'up' : 'down'}-${item.id}`)?.focus()

  saveOrder()
}
</script>

<template>
  <draggable
    v-model="tasks"
    item-key="id"
    tag="ul"
    handle=".handle"
    ghost-class="ghost"
    :animation="150"
    @change="saveOrder"
  >
    <template #item="{ element, index }">
      <li class="task">
        <!-- ドラッグできる部分。装飾なので、読み上げからは外す -->
        <span class="handle" aria-hidden="true">⠿</span>
        <span class="title">{{ index + 1 }}. {{ element.title }}</span>

        <button
          :id="`up-${element.id}`"
          type="button"
          :aria-label="`${element.title}を上へ移動`"
          :aria-disabled="index === 0"
          @click="moveItem(index, -1)"
        >↑</button>
        <button
          :id="`down-${element.id}`"
          type="button"
          :aria-label="`${element.title}を下へ移動`"
          :aria-disabled="index === tasks.length - 1"
          @click="moveItem(index, 1)"
        >↓</button>
      </li>
    </template>
  </draggable>

  <!-- 移動の結果を、読み上げソフトに伝える -->
  <p role="status">{{ message }}</p>
</template>

<style scoped>
.task { display: flex; gap: 0.5rem; align-items: center; padding: 0.5rem; border-bottom: 1px solid #ddd; }
.handle { cursor: grab; user-select: none; }
.title { flex: 1; }
.ghost { opacity: 0.4; background: #e3f2fd; }       /* ドラッグ中の位置を示す */
button[aria-disabled="true"] { opacity: 0.4; }
</style>

@changeと@endの違い

どちらも並び替えが終わったときに呼べますが、意味が違います。@endは、SortableJSのイベントで、ドラッグが終わるたびに発生し、元の位置に戻しただけの場合も呼ばれます。@changeは、バインドした配列がドラッグによって実際に変更されたときに発生し、引数にadded、removed、movedのいずれかが入ります。保存のように、変更があったときだけ実行したい処理には、@changeが向いています。

ドラッグ以外の操作手段を用意する

ドラッグ&ドロップは、マウスやタッチを前提とした操作です。キーボードだけで操作する人や、ドラッグの動作が難しい人は、並び替えができません。WCAG 2.2の達成基準2.5.7「ドラッグ操作」(レベルAA)は、ドラッグを必要とする機能を、ドラッグなしの、単一のポインターの操作でも使えるようにすることを求めています。W3Cの説明では、リストの並び替えの代替として、項目を選んで、近くの「上へ」「下へ」のボタンで動かす方法が挙げられています。完成版の「↑」「↓」ボタンが、その代替です。

  • ボタンには、どの項目を動かすのかが分かるaria-labelを付けます。
  • 先頭と末尾では、動かせない側のボタンをaria-disabledにします。
  • 移動した結果(「タスクAを2番目に移動しました」)を、role="status"で読み上げさせます。
  • DOMの並びが変わると、フォーカスが外れることがあります。移動後に、押したボタンへフォーカスを戻します。

保存と、失敗したときの扱い

並び替えの結果を保存するときは、並び順そのものではなく、IDの配列を送るのが扱いやすい方法です。通信が失敗したら、画面だけが保存されていない並びになってしまうため、最後に保存できた並びに戻し、利用者に知らせます。成功や失敗の通知には、トースト通知を作る方法が使えます。通信の状態の扱いは非同期APIの状態管理パターンをご覧ください。

関連記事

よくある質問(FAQ)

Qvuedraggableを入れたのに、Vue 3で動きません。
AVue 2向けの版が入っている可能性があります。Vue 3ではnpm i -S vuedraggable@nextで入れ、item-keyと#itemスロットで書いてください。
QSortableJSも、別にインストールする必要がありますか?
A必要ありません。vuedraggableが内部でSortableJSを使います。SortableJSのオプション(handle、animationなど)は、vuedraggableのpropsとして渡せます。
Qキーボードでは並び替えられませんか?
Aドラッグだけでは並び替えられません。「上へ」「下へ」のボタンなど、ドラッグを使わない操作手段を用意してください。WCAG 2.2の達成基準2.5.7でも、ドラッグを必要とする機能には、代替の操作が求められます。
Q保存は@endと@changeのどちらで呼ぶべきですか?
A変更があったときだけ保存したいなら@changeです。@endは、元の位置に戻しただけのドラッグでも呼ばれます。

まとめ

Vue 3のドラッグ&ドロップは、vuedraggable@nextに、配列をv-modelで結びつけるだけで作れます。item-keyにはidを使い、つかみ部分とアニメーションを整えます。実用にするには、キーボードでも使える「上へ」「下へ」のボタン、読み上げ、保存失敗時の巻き戻しも用意しておきましょう。