ライブラリを利用することなく短時間でシンプルなコードを利用してドラッグ&ドロップでテーブル行の並び替えをしたいと思ったことはないですか?本文書ではSvelte環境でドラッグ&ドロップを利用してテーブル行の並び替えを行う方法を説明しています。実装にはdragstart, dragend, dragover, dragenterイベントを利用しているのでSvelteでのdragイベントに馴染みがない人のdragイベントの学習にも利用することができます。

動作確認はmacOSを利用、Svelteのバージョンは3.52です。

プロジェクトの作成

npm create vite@latestコマンドを利用してSvelteプロジェクトを作成します。templateにはsvelteを指定します。プロジェクトの名前は任意の名前をつけることができます。ここではsvelte-table-row-reorderという名前をつけています。


 % npm create vite@latest svelte-table-row-reorder -- --template sveltet

プロジェクトの作成後は作成されたフォルダsvelte-table-row-reorderに移動します。


 % cd svelte-table-row-reorder

UserTable.svelteファイルのの作成

srcフォルダに存在するlibフォルダの下にUserTable.svelteファイルを作成します。作成後以下のコードを記述します。


<script>
</script>

<table>
  <div>UserTable</div>
</table>

srcフォルダのApp.svelteファイルを開いて作成したUserTableコンポーネントをimportします。


<script>
   import UserTable from './lib/UserTable.svelte';
</script>

<main>
  <UserTable />
</main>

npm run devコマンドで開発サーバを起動してUserTableコンポーネントの内容が表示されるか確認します。


 % npm run dev
UserTableコンポーネントの内容を確認
UserTableコンポーネントの内容を確認

データの取得

並び替えに利用するデータは無料で利用できるJSONPlaceHolderを利用します。https://jsonplaceholder.typicode.com/usersにアクセスすると10名分のユーザ情報を取得することができます。どのようなデータが取得できるかはブラウザからでも確認できます。

取得したデータを保存するためにローカル変数のusersを定義します。

データの取得はコンポーネントがマウント直後に実行されるonMount関数の中で実行します。fetch関数で取得したデータを定義したusers変数に保存します。users変数に保存された内容を表示するためにeach…ブロックによる配列の展開を行なっています。eachブロックに含まれている配列の要素番号であるindexは後ほど利用します。


<script>
  import { onMount } from 'svelte';
  let users = [];

  onMount(async () => {
    const response = await fetch('https://jsonplaceholder.typicode.com/users');
    const data = await response.json();
    users = data;
  });
</script>

<table>
  <thead>
    <tr>
      <th>ID</th>
      <th>名前</th>
      <th>ユーザ名</th>
      <th>Email</th>
    </tr>
  </thead>
  <tbody>
    {#each users as user, index (user.id)}
      <tr>
        <td>{user.id}</td>
        <td>{user.name}</td>
        <td>{user.username}</td>
        <td>{user.email}</td>
      </tr>
    {/each}
  </tbody>
</table>

ブラウザで確認すると10名分のユーザ情報が一覧で表示されます。

ユーザ一覧の表示
ユーザ一覧の表示

テーブルには罫線が表示されていないのでstyleタグを追加して設定を行います。

UserTable.sveluteファイルに以下を追加します。


<style>
table,
th,
td {
  border: 1px solid black;
  border-collapse: collapse;
  padding: 1em;
}
</style>

styleタグをUserTable.svelteファイルに追加後再度ブラウザを確認するとテーブルには罫線がついて表示されます。

罫線付きのテーブルの表示
罫線付きのテーブルの表示

テーブル行の並び替えを実装するための準備は完了です。

並び替え処理

draggableの設定

現在の設定では行の要素をマウスで掴みドラッグを行うことはできません。要素をドラッグできるようにするためにはdraggable属性を設定する必要があります。tr要素にdraggable属性を設定します。


{#each users as user, index (user.id)}
  <tr draggable={true}>
    <td>{user.id}</td>
    <td>{user.name}</td>
    <td>{user.username}</td>
    <td>{user.email}</td>
  </tr>
{/each}

設定後は移動した要素の上にマウスを移動して左ボタンで要素を掴むことができるようになります。ボタンを押している間要素をドラッグすることができます。下記の図はIDを10を持つtr要素をドラッグしています。

tr要素をドラッグ
tr要素をドラッグ

ドラッグした要素上で押していたボタンを外すとその場所に要素が追加できるように設定を行なっていきます。

dragstartイベントの設定

要素を掴んでドラッグを開始した直後に発火するdragstartイベントの設定を行います。dragstartイベントがドラッグ直後に発火するのかどうか確認を行います。dragstartイベントはon:dragstartで取得することができイベントが発火したらdragStart関数を実行します。


{#each users as user, index (user.id)}
  <tr draggable={true} on:dragstart={() => dragStart()}>
    <td>{user.id}</td>
    <td>{user.name}</td>
    <td>{user.username}</td>
    <td>{user.email}</td>
  </tr>
{/each}

設定したdragStart関数を定義します。


const dragStart = () => {
  console.log('drag start');
};

要素をドラッグしてブラウザのデベロッパーツールのコンソールに”drag start”が表示されることを確認してください。

確認できたら次はdragstartイベントでドラッグした要素のindex(配列の要素番号)を保存します。indexを保存するためにdragIndexを定義します。初期値をnullに設定しています。


let users = [];
let dragIndex = null;

dragstartイベントが発火した際にindexの情報を取得するためにdragStart関数の引数にindexを指定します。


{#each users as user, index (user.id)}
  <tr draggable={true} on:dragstart={() => dragStart(index)}>
    <td>{user.id}</td>
    <td>{user.name}</td>
    <td>{user.username}</td>
    <td>{user.email}</td>
  </tr>
{/each}

dragStart関数では引数で取得したindexを受け取ることができるので受け取ったindexにdragIndexに保存します。


const dragStart = (index) => {
  console.log('drag start', index);
  dragIndex = index;
};

設定後、要素をドラッグすると”drag start”の文字列の横にドラッグした要素のindexが表示されることを確認してください。indexは配列の要素番号なのでドラッグした要素によって0から9の値が表示されます。

dragstartイベントの設定は完了したのでconsole.logの行は削除しておきます。

dragenterイベントの設定

ドラッグした要素が別の要素に入った時に発火するdragenterイベントをtr要素に設定します。dragenterイベントが発火されるとdragEnter関数が実行され引数にはindexを指定しています。indexにはドラッグした要素が入ってきた要素のindexが入ります。ドラッグしている要素のindexではありません。


{#each users as user, index (user.id)}
  <tr
    draggable={true}
    on:dragstart={() => dragStart(index)}
    on:dragenter={() => dragEnter(index)}
  >
    <td>{user.id}</td>
    <td>{user.name}</td>
    <td>{user.username}</td>
    <td>{user.email}</td>
  </tr>
{/each}

dragEnter関数でドラッグした要素のindexが保存されているdragIndexとドラッグした要素が入ってきた要素のindexを確認します。


const dragEnter = (index) => {
  console.log('index', index);
  console.log('dragIndex', dragIndex.value);
};

同じ要素をドラッグしている間はdragIndexは同じ値が表示され、tr要素を超えていく度に異なるindexがコンソールに表示されることを確認してください。

確認ができたら、indexとdragIndexの値が異なる場合のみ要素の入れ替えを行う処理を追加します。


const dragEnter = (index) => {
  if (index === dragIndex) return;
  let newUsers = JSON.parse(JSON.stringify(users));
  const deleteElement = newUsers.splice(dragIndex, 1)[0];
  newUsers.splice(index, 0, deleteElement);
  users = newUsers;
  dragIndex = index;
};

JSON.parse(JSON.stringify(users))でusersの配列のコピーをnewUsers変数に保存します。spliceメソッドとdragIndexを利用してドラッグした要素をnewUsersの配列から削除しています。削除した要素(ドラッグした要素)がspliceメソッドの戻り値の配列番号の0に入っているのでdeleteElementに保存しています。再度spliceメソッドを実行して今度はindexの場所に削除した要素を追加しています。これで入れ替え処理を行うことができます。

spliceメソッドの処理の詳しい内容についてはMDNなどで確認してください。

配列を入れ替えた後はdragIndexにドラッグした要素が入れ替わった後のindexを設定しています。

これで並び替えの処理は完了です。一番上のIDが1の要素をドラッグして一番下の要素に移動できるか確認してください。

一番上の要素を一番下に移動した場合
一番上の要素を一番下に移動した場合

dragoverイベント

OSやブラウザによってドラッグで要素を移動した後にドロップする(並び替えは完了)と半透明の要素が元の場所に戻るような動作をします。その動作を止めるためにdragoverイベントを設定します。dragoverイベントではデフォルトの処理を停止するためpreventDefaultを実行します。dragoverイベントでpreventDefaultを実行するとドラッグした要素をドロップしても半透明の要素が元の場所に戻るという動作がなくなります。


{#each users as user, index (user.id)}
  <tr
    draggable={true}
    on:dragstart={() => dragStart(index)}
    on:dragenter={() => dragEnter(index)}
    on:dragover|preventDefault
  >
    <td>{user.id}</td>
    <td>{user.name}</td>
    <td>{user.username}</td>
    <td>{user.email}</td>
  </tr>
{/each}

dragendイベント

ここではJSONPlaceHolderを利用しているのでデータの取得を行うことはできますがデータの保存を行うことができません。実際のアプリケーションでは並び替えを行なった後のデータを保存する処理が必要になります。データ保存の処理を行う場所としてdragendイベントを利用します。

dragendイベントはドロップした際に発火されます。


{#each users as user, index (user.id)}
  <tr
    draggable={true}
    on:dragstart={() => dragStart(index)}
    on:dragenter={() => dragEnter(index)}
    on:dragover|preventDefault
    on:dragend={dragEnd}
  >
    <td>{user.id}</td>
    <td>{user.name}</td>
    <td>{user.username}</td>
    <td>{user.email}</td>
  </tr>
{/each}

dragEnd関数を追加し、ドラッグを止めた(ドロップ)時にイベントが発火するか確認します。


const dragEnd = () => {
  console.log('drop');
};

ドラッグのために押していたボタンを離した時にコンソールに”drop”が表示されることを確認してください。

並び替え後のデータをサーバに送信する等の処理をdragEnd関数の中に記述します。dragEndが実行される時は並び替えの処理が完了しているのでdragIndexの値を初期値のnullに戻しておきます。


const dragEnd = () => {
  // 並び替え後のusers配列をサーバに送信する処理を追加する
  dragIndex.value = null;
};

ドラッグ要素を目立たせる

ドラッグした要素を目立たせるためにドラッグ要素にスタイルを設定します。

indexとdragIndexの値が等しい時にclassのdraggingを適用します。


{#each users as user, index (user.id)}
  <tr
    draggable={true}
    on:dragstart={() => dragStart(index)}
    on:dragenter={() => dragEnter(index)}
    on:dragover|preventDefault
    on:dragend={dragEnd}
    class={index === dragIndex ? 'dragging' : ''}
  >
    <td>{user.id}</td>
    <td>{user.name}</td>
    <td>{user.username}</td>
    <td>{user.email}</td>
  </tr>
{/each}

draggingクラスはscriptタグに追加します。


<script>
table,
th,
td {
  border: 1px solid black;
  border-collapse: collapse;
  padding: 1em;
}

.dragging {
  background-color: #eee;
}
</script>

ドラッグしている要素にスタイルを適用することでドラッグしている要素が先ほどよりもわかりやすくなりました。

ドラッグした要素にスタイルを適用
ドラッグした要素にスタイルを適用

作成したUserTable.svelteファイルは下記の通りです。


<script>
  import { onMount } from 'svelte';
  let users = [];
  let dragIndex = null;

  onMount(async () => {
    const response = await fetch('https://jsonplaceholder.typicode.com/users');
    const data = await response.json();
    users = data;
  });

  const dragStart = (index) => {
    dragIndex = index;
  };

  const dragEnter = (index) => {
    if (index === dragIndex) return;
    let newUsers = JSON.parse(JSON.stringify(users));
    const deleteElement = newUsers.splice(dragIndex, 1)[0];
    newUsers.splice(index, 0, deleteElement);
    users = newUsers;
    dragIndex = index;
  };

  const dragEnd = () => {
    // 並び替え後のusers配列をサーバに送信する処理を追加する
    dragIndex = null;
  };
</script>

<table>
  <thead>
    <tr>
      <th>ID</th>
      <th>名前</th>
      <th>ユーザ名</th>
      <th>Email</th>
    </tr>
  </thead>
  <tbody>
    {#each users as user, index (user.id)}
      <tr
        draggable={true}
        on:dragstart={() => dragStart(index)}
        on:dragenter={() => dragEnter(index)}
        on:dragover|preventDefault
        on:dragend={dragEnd}
        class={index === dragIndex ? 'dragging' : ''}
      >
        <td>{user.id}</td>
        <td>{user.name}</td>
        <td>{user.username}</td>
        <td>{user.email}</td>
      </tr>
    {/each}
  </tbody>
</table>

<style>
  table,
  th,
  td {
    border: 1px solid black;
    border-collapse: collapse;
    padding: 1em;
  }

  .dragging {
    background-color: #eee;
  }
</style>