15
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2019-08-29

概要

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

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

環境

サンプル

今回実装するコンポーネントのサンプルです。
このサンプルではQiitaのAPIで、Qiitaに投稿された記事を記事名でインクリメンタルサーチします。
上が単一選択で下が複数選択です
combotest.gif

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

以下は解説です。

ソースコード

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

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>

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

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

まとめ

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

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

15
17
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?