Vueでイケてるインクリメンタルサーチのコンポーネントを作る

# 概要
入力値から自動的に候補を絞り込むUIってよく見かけます。
例えば、Slackとかでも"@"を入力して文字を入力すると
メンション先が自動的に絞り込まるので使いやすいですね。
こういうUIのことを**インクリメンタルサーチ**と呼ぶらしいです。

今回は**Vueでイケてるインクリメンタルサーチのコンポーネント**を作ります。

## 環境
- Vue 2.6
- Vuetify 1.3
    - VComboboxを拡張します。https://vuetifyjs.com/ja/components/combobox
    - Vuetify2.xを使用する場合、破壊的な変更により動かない場合があるためご注意ください

## サンプル
今回実装するコンポーネントのサンプルです。
このサンプルではQiitaのAPIで、Qiitaに投稿された記事を記事名でインクリメンタルサーチします。
上が単一選択で下が複数選択です
![combotest.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/198781/395891b9-96ec-85c4-240d-acf6bc71f593.gif)

全体のソースコードはGitHubにおいています
https://github.com/koushisa/incremental-search-sample

以下は解説です。
### ソースコード

コンポーネントを使用している画面です。

```vuejs:App.vue

<template>
    <v-content>
      <v-container fluid>
        <v-layout column>
          <v-flex>
            <app-combobox
              label="Qiitaの記事を検索"
              :model.sync="singleItem"
              :search-input.sync="query1"
              :search-func="searchQiita1"
              :search-results="qiitaItems"
              item-text="title"
              item-sub-text="url"
              item-identifier="url"
              single
            ></app-combobox>
          </v-flex>
          <v-flex>
            <app-combobox
              label="Qiitaの記事を選択(複数選択可能)"
              :model.sync="multiItem"
              :search-input.sync="query2"
              :search-func="searchQiita2"
              :search-results="qiitaItems"
              item-text="title"
              item-sub-text="url"
              item-identifier="url"
            ></app-combobox>
          </v-flex>
        </v-layout>
      </v-container>
    </v-content>
</template>

<script>
import axios from "axios";
export default {
  data() {
    return {
      singleItem: null,
      multiItem: [],

      //記事の検索条件(前方一致)
      query1: "",
      query2: "",

      //記事検索のレスポンス
      qiitaItems: []
    };
  },

  methods: {
    //記事検索
    searchQiita1() {
      axios
        .get(`https://qiita.com/api/v2/items?query=${this.query1}`)
        .then(response => (this.qiitaItems = response.data));
    },
    searchQiita2() {
      axios
        .get(`https://qiita.com/api/v2/items?query=${this.query2}`)
        .then(response => (this.qiitaItems = response.data));
    }
  }
};
</script>

```

こちらはインクリメンタルサーチのコンポーネントです

```vuejs:AppCombobox.vue
<template>
  <v-combobox
    v-model="_model"
    :label="label"
    :items="searchResults"
    no-filter
    :search-input.sync="_searchInput"
    :allow-overflow="false"
    hide-no-data
    return-object
    :item-text="itemText"
    :item-value="itemIdentifier"
    chips
    small-chips
    deletable-chips
    :multiple="!single"
    :disabled="readonly"
  >
    <!-- 選択済みの要素はチップで表示 -->
    <template v-slot:selection="data">
      <!-- 単一 -->
      <template v-if="single">
        <v-chip
          v-if="typeof model[itemText] !== 'undefined'"
          :selected="data.selected"
          close
          class="chip--select-multi"
          @input="remove(model)"
        >{{ model[itemText]}}</v-chip>
      </template>
      <!-- 複数 -->
      <template v-else>
        <v-chip
          v-if="typeof data.item[itemText] !== 'undefined'"
          :selected="data.selected"
          close
          class="chip--select-multi"
          @input="remove(data.item)"
        >{{ data.item[itemText]}}</v-chip>
      </template>
    </template>

    <!-- 検索結果の表示部 -->
    <template v-slot:item="data">
      <template v-if="typeof data.item !== 'object'">
        <v-list-tile-content v-text="data.item"></v-list-tile-content>
      </template>
      <template v-else>
        <v-list-tile-content>
          <v-list-tile-title v-text="data.item[itemText]"></v-list-tile-title>
          <v-list-tile-sub-title v-text="data.item[itemSubText]"></v-list-tile-sub-title>
        </v-list-tile-content>
      </template>
    </template>
  </v-combobox>
</template>

<script>
import { throttle, isEmpty } from "lodash";

export default {
  props: {
    model: {
      type: [Object, Array, String],
      default: null
    },
    searchInput: {
      type: String,
      default: ""
    },
    searchFunc: {
      type: Function,
      required: true
    },
    searchResults: {
      type: Array,
      required: true
    },
    label: {
      type: String,
      default: ""
    },
    itemText: {
      type: String,
      default: ""
    },
    itemSubText: {
      type: String,
      default: ""
    },
    itemIdentifier: {
      type: String,
      default: ""
    },
    single: {
      type: Boolean
    },
    readonly: {
      type: Boolean,
      default: false
    },
  },
  computed: {
    _model: {
      get() {
        return this.model;
      },
      set(newVal) {
        return this.$emit("update:model", newVal);
      }
    },
    _searchInput: {
      get() {
        return this.searchInput;
      },
      set(newVal) {
        return this.$emit("update:search-input", newVal);
      }
    }
  },
  watch: {
    //インクリメンタルサーチ
    searchInput(val, prev) {
      if (!isEmpty(val)) {
        this.searchWithInterval();
      }
    },
    // APIで取得した値のみ入力可能にします。
    model(val, prev) {
      //単一
      if (this.single) {
        if (val === null) {
          this.$emit("update:model", prev);
          return;
        }
        if (typeof val === "string") {
          this.$emit("update:model", prev);
          return;
        }
      } else {
        // 複数
        if (val.length === 0) {
          return;
        }
        if (typeof val[val.length - 1][this.itemIdentifier] === "undefined") {
          this.model.splice(this.model.length - 1, 1);
        }
      }
    }
  },
  created() {
    //APIの実行を0.5秒に1回に制限します。
    this.searchWithInterval = throttle(this.searchFunc, 500);
  },
  methods: {
    //チップを削除します。
    remove(selected) {
      //単一
      if (this.single) {
        this.$emit("update:model", {});
      } else {
        //複数
        const index = this.model.findIndex(
          item => item[this.itemIdentifier] === selected[this.itemIdentifier]
        );
        if (index >= 0) {
          this.model.splice(index, 1);
        }
      }
    }
  }
};
</script>

```

## 解説
VComboboxをラップし、より汎用的に使えるようにしました。
検索結果の候補からしか入力できないようにするため、watchで小細工をしています。

使用できるプロパティは下記のようになっています。
今回はVComboboxの中で最低限のプロパティを使えるようにしました。

| プロパティ | 型| 備考 |
| --- | --- | --- |
| model | [Object, Array, String], | 候補から選択したオブジェクトをバインド<br>後述のsingleを指定しない場合はArray型となる|
| searchInput|String | 入力値をバインド<br>この値をもとにインクリメンタルサーチを行う| 
| searchFunc|Function | 検索処理| 
| searchResults|Array | 検索結果<br>この結果が候補に表示される| 
| label | String |ラベル | 
| itemText | String |候補のメインテキスト<br>候補のオブジェクトから表示したいキーを指定 | 
| itemSubText | String |候補のサブテキスト<br>候補のオブジェクトから表示したいキーを指定 | 
| itemIdentifier|String | 候補のオブジェクトから一意になるキーを指定
| single | Boolean | 検索結果から1つしか選択できないようにする|
| readonly | Boolean | 読み取り専用にする|

## まとめ

Vuetifyは個人的に使いやすいので気に入ってますが、
公式のドキュメントは若干不親切な感じはありますね。。。

使える機能は多いので、うまく付き合えるとかなりの価値を発揮できそうです。