【Web】配列の要素をドラッグ&ドロップで並べ替えられるようにする

# この記事の目的

配列の要素をWebページに表示し、それらをドラッグ&ドロップすることで配列の要素を並べ替えることができるようにする。

![要素の並べ替え](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99656/789bb70b-cd1f-b561-e5e5-b118ec957637.png)

# 配列の要素をWebページに表示する

まずは、配列の要素をWebページに表示する。
後に、ドラッグ&amp;ドロップにより配列の最後に移動できるようにするため、最後の要素の後に1要素分空白を作る。

表示するためのDOM要素を入れる要素を用意する。

```html:HTML
<div id="ddlistarea"></div>
```

適当に枠をつける。
後で使うため、表示するための要素は `div` を二重にし、枠は内側の要素につける。

```css:CSS
#ddlistarea {
	border: 1px solid black;
	padding: 2px;
	display: inline-grid;
	width: 10em;
}

#ddlistarea > div {
	min-height: 1em;
	text-align: center;
	box-sizing: border-box;
}

#ddlistarea > div:not(:last-child) {
	padding-bottom: 2px;
}

#ddlistarea > div:not(:last-child) > div {
	border: 1px solid black;
}
```

配列と、その中身をDOM要素に入れるコードを用意する。
使い回せるDOM要素は使い回すようにしてもいいかもしれないが、今回は簡単のため毎回全要素を撤去して全体を作り直す。

```javascript:JavaScript
const elemList = ["A", "B", "C", "D", "E"];

const area = document.getElementById("ddlistarea");

function render() {
	while (area.firstChild) area.removeChild(area.firstChild);
	for (let i = 0; i <= elemList.length; i++) {
		const elem = document.createElement("div");
		if (i < elemList.length) {
			const labelElem = document.createElement("div");
			labelElem.textContent = elemList[i];
			elem.appendChild(labelElem);
		}
		area.appendChild(elem);
	}
}

render();
```

# ドラッグ&amp;ドロップができるようにする

[ドラッグ操作 - Web API | MDN](https://developer.mozilla.org/ja/docs/Web/API/HTML_Drag_and_Drop_API/Drag_operations)
に沿って、要素をドラッグ&amp;ドロップできるようにする。

## ドラッグされる側

ドラッグされる側としてのセットアップは、一番下の空白を表す要素には行わず、それ以外の要素 (配列の要素を表す要素) に対して行う。

まず、要素に [`draggable="true"` 属性](https://developer.mozilla.org/ja/docs/Web/HTML/Global_attributes#draggable)を設定する。

```javascript:JavaScript
elem.setAttribute("draggable", "true");
```

そして、ドラッグが開始されたことを表す [`dragstart`](https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/dragstart_event) イベントで、以下を行う。

* [`setData`](https://developer.mozilla.org/ja/docs/Web/API/DataTransfer/setData) メソッドにより、ドロップされる側に渡すデータを設定する
* [`effectAllowed`](https://developer.mozilla.org/ja/docs/Web/API/DataTransfer/effectAllowed) により、許可するドロップ時の操作を設定する

今回は、`text/x-list-index` としてドラッグされる要素 (すなわち、移動する要素) の位置を渡す。
また、移動のみを許可し、コピーおよびリンクは許可しない。

```javascript:JavaScript
elem.addEventListener("dragstart", (event) => {
	event.dataTransfer.setData("text/x-list-index", elemIndex.toString());
	event.dataTransfer.effectAllowed = "move";
});
```

## ドロップされる側

ドラッグされる側としてのセットアップは、一番下の空白を表す要素を含むすべての要素に対して行う。

以下のイベントのハンドラーを登録する。

|イベント|発生する場面|
|---|---|
|[`dragenter`](https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/dragenter_event)|ドラッグ中のカーソルが要素内に入った|
|[`dragover`](https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/dragover_event)|ドラッグ中のカーソルが要素内にある|
|[`dragleave`](https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/dragleave_event)|ドラッグ中のカーソルがドロップせずに要素内から出た|
|[`drop`](https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/drop_event)|ドラッグ中のカーソルが要素内でドロップをした|

`dragenter` イベントでは、今回用いる `text/x-list-index` のデータがあるかを確認し、ある場合は以下を行う。

* ドロップを許可するため、`event.preventDefault();` を呼び出す
* ドロップ先が配列の要素を表す要素の場合は、その前に挿入されることを表すため、`draggedover` クラスをつける

`draggedover` クラスをつけると、CSSの設定により、要素上部のパディングが増える。
このパディングによって要素を囲む枠を大きくするのではなくずらすため、当たり判定となる外側の `div` と枠を表示する内側の `div` に分けた構造とした。
ただし、今回の構造では、外側の `div` 内にカーソルがある状態でも、内側の `div` にカーソルが出入りすると、外側と内側それぞれの `div` について `dragenter` イベントや `dragleave` イベントが発生するようであった。
そこで、カーソルの出入りをカウントする変数を用い、クラスの設定が適切に行えるようにした。

```javascript:JavaScript
elem.addEventListener("dragenter", (event) => {
	if (event.dataTransfer.types.includes("text/x-list-index")) {
		event.preventDefault();
		if (i < elemList.length) {
			elem.classList.add("draggedover");
			enterCount++;
		}
	}
});
```

```css:CSS
#ddlistarea > div.draggedover {
	padding-top: 0.5em;
}
```

`dragover` イベントは、ドラッグ中のカーソルがドロップ先の上にある間短い間隔で発生し、カーソルがある位置を用いたフィードバックの描画などに役立つ。
今回は、そのような細かい描画は行わないが、ドロップを許可するために `event.preventDefault();` を呼び出すことが求められるので、呼び出す。

```javascript:JavaScript
elem.addEventListener("dragover", (event) => {
	if (event.dataTransfer.types.includes("text/x-list-index")) {
		event.preventDefault();
	}
});
```

`dragleave` イベントは、ドラッグ中のカーソルがドロップせずにドロップ先を離れたとき発生する。
このとき、カーソルの出入りのカウントを行い、完全に要素を出たと判断したら `draggedover` クラスを取り除く。

```javascript:JavaScript
elem.addEventListener("dragleave", (event) => {
	if (event.dataTransfer.types.includes("text/x-list-index")) {
		if (enterCount > 0 && --enterCount <= 0) elem.classList.remove("draggedover");
	}
});
```

:::note info
`dragleave` イベントは、ドラッグ中のカーソルがドロップ先を離れずにドロップをすることでドラッグが終了した場合、発生しないようである。
そのため、ドロップ時のイベント `drop` においても、このカウント処理が必要である。
:::

`drop` イベントは、ドラッグ中のカーソルがドロップをしたとき発生する。
今回用いる `text/x-list-index` のデータがある場合、以下を行う。

* ドロップのデフォルト動作 (ページの移動など) をキャンセルする
* カーソルの出入りのカウントを更新する
* `text/x-list-index` のデータを読み取り、有効であれば配列の更新と再描画を行う

```javascript:JavaScript
elem.addEventListener("drop", (event) => {
	if (event.dataTransfer.types.includes("text/x-list-index")) {
		event.preventDefault();
		if (enterCount > 0 && --enterCount <= 0) elem.classList.remove("draggedover");
		const fromIndex = parseInt(event.dataTransfer.getData("text/x-list-index"), 10);
		if (!isNaN(fromIndex) && 0 <= fromIndex && fromIndex < elemList.length) {
			// fromIndex の要素を、この要素 (elemIndex) の直前に挿入する
			const [data] = elemList.splice(fromIndex, 1);
			elemList.splice(elemIndex - (fromIndex < elemIndex ? 1 : 0), 0 ,data);
			// 再描画を行う
			render();
		}
	}
});
```

# コード全体・デモ

```html:HTML
<div id="ddlistarea"></div>
```

```css:CSS
#ddlistarea {
	border: 1px solid black;
	padding: 2px;
	display: inline-grid;
	width: 10em;
}

#ddlistarea > div {
	min-height: 1em;
	text-align: center;
	box-sizing: border-box;
}

#ddlistarea > div:not(:last-child) {
	padding-bottom: 2px;
}

#ddlistarea > div:not(:last-child) > div {
	border: 1px solid black;
}

#ddlistarea > div.draggedover {
	padding-top: 0.5em;
}
```

```javascript:JavaScript
const elemList = ["A", "B", "C", "D", "E"];

const area = document.getElementById("ddlistarea");

function render() {
	while (area.firstChild) area.removeChild(area.firstChild);
	for (let i = 0; i <= elemList.length; i++) {
		const elemIndex = i;
		const elem = document.createElement("div");
		let enterCount = 0;
		if (i < elemList.length) {
			const labelElem = document.createElement("div");
			labelElem.textContent = elemList[i];
			elem.appendChild(labelElem);
			elem.setAttribute("draggable", "true");
			elem.addEventListener("dragstart", (event) => {
				event.dataTransfer.setData("text/x-list-index", elemIndex.toString());
				event.dataTransfer.effectAllowed = "move";
			});
		}
		elem.addEventListener("dragenter", (event) => {
			if (event.dataTransfer.types.includes("text/x-list-index")) {
				event.preventDefault();
				if (i < elemList.length) {
					elem.classList.add("draggedover");
					enterCount++;
				}
			}
		});
		elem.addEventListener("dragover", (event) => {
			if (event.dataTransfer.types.includes("text/x-list-index")) {
				event.preventDefault();
			}
		});
		elem.addEventListener("dragleave", (event) => {
			if (event.dataTransfer.types.includes("text/x-list-index")) {
				if (enterCount > 0 && --enterCount <= 0) elem.classList.remove("draggedover");
			}
		});
		elem.addEventListener("drop", (event) => {
			if (event.dataTransfer.types.includes("text/x-list-index")) {
				event.preventDefault();
				if (enterCount > 0 && --enterCount <= 0) elem.classList.remove("draggedover");
				const fromIndex = parseInt(event.dataTransfer.getData("text/x-list-index"), 10);
				if (!isNaN(fromIndex) && 0 <= fromIndex && fromIndex < elemList.length) {
					// fromIndex の要素を、この要素 (elemIndex) の直前に挿入する
					const [data] = elemList.splice(fromIndex, 1);
					elemList.splice(elemIndex - (fromIndex < elemIndex ? 1 : 0), 0 ,data);
					// 再描画を行う
					render();
				}
			}
		});
		area.appendChild(elem);
	}
}

render();
```

<p class="codepen" data-height="300" data-default-tab="result" data-slug-hash="mybONrL" data-pen-title="array drag &amp;amp; drop demo" data-user="mike_cat" style="height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/mike_cat/pen/mybONrL">
  array drag &amp; drop demo</a> by MikeCAT (<a href="https://codepen.io/mike_cat">@mike_cat</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

# まとめ

JavaScriptの配列の要素をDOM要素を用いてWebページに表示し、ドラッグ&amp;ドロップ関係の属性やイベントハンドラーを設定することで、ドラッグ&amp;ドロップにより配列の要素の並べ替え操作を行うことができるようにできた。