【jQueryUIを使わずにドラッグ＆ドロップを実装したい】Javascriptライブラリ「Sortable」を使ってみた

HTML5の主要機能でもあるドラッグ＆ドロップですが、
普段、UI周りの実装をすることが少ないエンジニアの方には馴染みが薄い処理の一つではないでしょうか。
私もAPIの存在は知っていたものの、最近まで実装したことがなかったのですが、ついこの間WEBサービスのリニューアル案件で使う機会があったのでその時にインプットした**Sortable**というライブラリを紹介したいと思います。

## jQueryUIを使わずに実装できるライブラリ「Sortable」
自分は特に反jQuery側の人間ではないのですが、やっぱり依存するライブラリが少ないというのは組んでいて安心しますよね！そんな時に見つけたのがこの**Sortable**です。
[Sortable](https://github.com/RubaXa/Sortable) / [デモページ](http://sortablejs.github.io/Sortable/)

主要ブラウザ（IE9含む）はもちろん、タッチデバイスにも対応している優れものです。
そんな万能なライブラリですが、日本語のドキュメントが少ないというのが導入における唯一のリスクだったのですが、
幸いなことに案件のスパンも長かったため、検証も兼ねて初めて自分でドキュメントの日本語化をしてみました。
（ところどころ怪しい部分がありますが、優しくコメントをいただけると助かります）

### 基本的な使い方
![capani.gif](https://qiita-image-store.s3.amazonaws.com/0/101111/5eb00e13-1447-5a7b-c3a8-05530b9bf7fe.gif)

```javascript:script
let sortable = Sortable.create(hoge, {
	group: "hoge",
	animation: 100
});
```

基本的な使い方としては、
`Sortable.create()`の第一引数にドラッグ＆ドロップを適用させるリストのIDを指定し、第二引数に適用させるオプションを指定します。

### オプション
| オプション名 | デフォルト値 | 説明 |
|:-----------|:-----------|:-----------|
| group | "name" | ドラッグ＆ドロップするグループ名（名前はなんでもいい） |
| sort | true | リスト内でのソート可否 |
| delay | 0 | 並び替えを開始するタイミング |
| disabled | false | sortオプションがtrueに設定されている際に、ソートを無効化するか |
| store | null | リストの順番の保存と取得 |
| animation | 150 | ソート時のアニメーションスピード |
| handle | ".my-handle" | ドラッグできる範囲の指定（そのままだとテキスト選択ができないため） |
| filter | ".ignore-elements" | ドラッグさせたくないセレクタを指定 |
| preventOnFilter | true | filterがトリガされたときに「event.preventDefault()」を呼び出すかどうか |
| draggable | ".item" | リスト内のどの部分をドラッグ可能にするかを指定 |
| ghostClass | "sortable-ghost" | ドロップ中のクラス名の指定 |
| chosenClass | "sortable-chosen" | 選択中のクラス名の指定 |
| dragClass | "sortable-drag" | ドラッグ中のクラス名の指定 |
| dataIdAttr | 'data-id' | 不明 |
| forceFallback | false | trueに設定すると古いブラウザのフォールバックが適用される（開発用） |
| fallbackClass | "sortable-fallback" | forceFallbackを使用したときに複製されたDOM要素のクラス名 |
| fallbackOnBody | false | forceFallbackを使用したときにcloneされたDOM要素をドキュメントの本文に追加するか |
| fallbackTolerance | 0 | forceFallbackを使用したときにドラッグすることができる範囲をピクセルで指定 |
| scroll | true | ドラッグしながらスクロールできるようにするか |
| scrollFn | function(offsetX, offsetY, originalEvent) { ... } | 専用のスクロール機能を持​​つカスタムスクロールバーがある場合に使用 |
| scrollSensitivity | 30 | スクロールが開始する位置をピクセルで指定 |
| scrollSpeed | 10 | スクロールするスピードをピクセルで指定 |


### イベント
| イベント名 | 説明 |
|:-----------|:-----------|:-----------|
| setData | データを格納・保存した時 |
| onChoose | 要素を選択している時 |
| onStart | ドラッグが開始された時 |
| onEnd | ドラッグが終了した時 |
| onAdd | リスト内に新たに要素が追加された時 |
| onUpdate | ソートされリスト内が新たに更新された時 |
| onSort | リストの変更（追加/更新/削除）がされた時 |
| onRemove | 要素がリストから別のリストに移動し削除された時 |
| onFilter | filterで指定された要素をドラッグしようとした時 |
| onMove | リスト内またはリスト間で項目を移動した時 |
| onClone | 要素が複製された時 |


## 「Sortable」を使用した実装例
基本的な使い方とオプションなどが理解できたところで、様々な実装例を見ていきたいと思います。

### ドラッグできるポインタの範囲を指定する
![capani2.gif](https://qiita-image-store.s3.amazonaws.com/0/101111/e82a50f2-8c59-7316-ed84-63710f340e87.gif)

```javascript:script
let sortable = Sortable.create(hoge, {
  group: "hoge",
  handle: ".my-handle",
  animation: 100
});
```

特に何も指定せずにそのままの状態だと、ドラッグに反応してリスト内の項目上でテキストを選択することができません。
そこで、`handle`オプションを使ってドラッグできるポインタの範囲を指定してあげることで、項目の用途はそのままにドラッグ＆ドロップさせることができます。

### 複数のリスト間でドラッグ＆ドロップ
![capani3.gif](https://qiita-image-store.s3.amazonaws.com/0/101111/6848a6b4-3cfb-ca7f-32d9-62f31d57795f.gif)

```javascript:script
Sortable.create(hoge, {
  group: {
    name: "shares",
  },
  animation: 100
});

Sortable.create(fuga, {
  group: {
    name: "shares",
  },
  animation: 100
});

// または

Sortable.create(hoge, {
  group: {
    name: "hoge",
    put: "fuga",
  },
  animation: 100
});

Sortable.create(fuga, {
  group: {
    name: "fuga",
    put: "hoge",
  },
  animation: 100
});
```

複数あるリスト間でお互いの項目をドラッグ＆ドロップさせるには、二通り実装する方法があります。
一つは、`group`の`name`の値を同じ値に設定してあげます。
もう一つは、`group`の`put`で引き受けるリストの`name`をそれぞれ指定します。

使い分けは時と場合によりますが、
この場合は前者の方が移動するリストが増えた際に楽なため、前者の方をおすすめします。


### ドラッグさせない項目を指定する
![capani4.gif](https://qiita-image-store.s3.amazonaws.com/0/101111/d4c6e515-f319-a510-71f8-5324735c8d7c.gif)

```javascript:script
Sortable.create(hoge, {
  group: {
    name: "shares",
  },
  filter: ".ignore-elements",
  animation: 100
});

Sortable.create(fuga, {
  group: {
    name: "shares",
  },
  filter: ".ignore-elements",
  animation: 100
});
```

項目の中には、ドラッグさせたくないものが発生する場合もあります。
その場合には、オプションの`filter`を用いドラッグさせたくない要素を指定します。

### リストの削除と復活
![capani5.gif](https://qiita-image-store.s3.amazonaws.com/0/101111/4950be58-b13f-f21f-0722-97236f24aa22.gif)

```javascript:script
Sortable.create(hoge, {
  group: {
    name: "hoge",
  },
  animation: 100
});

var editableList = Sortable.create(fuga, {
  group: {
    name: "fuga",
    put: ["hoge"],
  },
  filter: ".js-remove",
  onAdd: function (evt) {
    var el = editableList.closest(evt.item);
    el.innerHTML += '<i class="js-remove">✖</i>';
  },
  onFilter: function (evt) {
    var el = editableList.closest(evt.item);
    var hoge = document.getElementById("hoge");
    var removeBtn = document.querySelector("#fuga li .js-remove");
    el.removeChild(removeBtn);
    el && el.parentNode.removeChild(el);
    hoge.appendChild(el);
  },
  animation: 100,
});
```

Wordpressのウィジェット管理画面のような、項目の追加と削除・復活を実装します。

```javascript
onAdd: function (evt) {
  var el = editableList.closest(evt.item);
  el.innerHTML += '<i class="js-remove">✖</i>';
},
```

`onAdd`イベントを使い、hogeリストからfugaリストに項目が追加されたことを検知し、
removeボタンを追加します。

```javascript
onFilter: function (evt) {
  var el = editableList.closest(evt.item);
  var hoge = document.getElementById("hoge");
  var removeBtn = document.querySelector("#fuga li .js-remove");
  el.removeChild(removeBtn);
  el && el.parentNode.removeChild(el);
  hoge.appendChild(el);
},
```

`onFilter`イベントを使い、removeボタンがクリックされたことを検知し、
ボタンの削除とその削除されたボタンを元のリストに復活させます。



### リストの削除と復活（別パターン）
![capani6.gif](https://qiita-image-store.s3.amazonaws.com/0/101111/6bc479ea-44c1-ae4c-0f77-406dfa99ba9b.gif)

```javascript:script
Sortable.create(hoge, {
  group: {
    name: "hoge",
    pull: function () {
      return "clone";
    }
  },
  sort: false,
  animation: 100
});

var editableList = Sortable.create(fuga, {
  group: {
    name: "fuga",
    put: ["hoge"],
  },
  filter: ".js-remove",
  onAdd: function (evt) {
    var el = editableList.closest(evt.item);
    el.innerHTML += '<i class="js-remove">✖</i>';
  },
  onFilter: function (evt) {
    var el = editableList.closest(evt.item);
    el && el.parentNode.removeChild(el);
  },
  animation: 100,
});
```

先ほどの実装例とは多少異なりますが、
追加する元のリストの項目は削除せずに、移動先にドラッグ＆ドロップした項目をクローンしてあげるだけで済む場合もあるかもしれません。
その場合には、`group`の`pull`で`'clone'`を返してあげるようにします。

そうすることで、元のリスト内の項目は削除されずに自分のコピーを移動先に渡してあげることができます。

### 項目の順番を保存して取得
![capani7.gif](https://qiita-image-store.s3.amazonaws.com/0/101111/905d023a-7fcc-3743-c89e-33e9fcfd2c91.gif)

```html:html
<ul id="hoge">
  <li data-id="1">element 1</li>
  <li data-id="2">element 2</li>
  <li data-id="3">element 3</li>
</ul>
```

```javascript:script
Sortable.create(hoge, {
  group: "save",
  store: {
    get: function (sortable) {
      var order = localStorage.getItem(sortable.options.group.name);
      return order ? order.split('|') : [];
    },
    set: function (sortable) {
      var order = sortable.toArray();
      localStorage.setItem(sortable.options.group.name, order.join('|'));
    }
  }
});
```

順番を保存するには、
HTMLの`data-id`で設定している順番を、`store`の`set`でlocalStorageに保存します。

反対に、保存した順番を取得するには、
`store`の`get`を用い取得し、getしたものをreturnさせてあげることで、リロードする前の項目順で表示されます。


## まとめ
少ない記述で、なおかつ、拡張性も高いため、
ある程度の規模と機能には対応できるライブラリかなという印象です。
また、肝心のパフォーマンスもスムーズに動いてくれているため（SP/TBも）、このライブラリを選んで正解でした。

今後クライアントのフィードバッグを受けて、より深いところまで動かしてみようと思います。
細々出てきた問題に関しては、随時更新していきたいと思います。


## 日本語化してみて
今回初めて日本語ドキュメントがほとんど無いライブラリを検証してみましたが、
改めてやってみると、検証しながら意味を調べたりすることで、
そのオプションの持っている意味などが、普段誰かがドキュメントに起こしてくれたものを見て理解するより、数倍理解を深めることができたなと思いました。

今回のように時間が取れるかどうかはわかりませんが、次回も新しいものにどんどん触れていきたいと思います！
では、また次回！