概要
入力値から自動的に候補を絞り込むUIってよく見かけます。
例えば、Slackとかでも"@"を入力して文字を入力すると
メンション先が自動的に絞り込まるので使いやすいですね。
こういうUIのことをインクリメンタルサーチと呼ぶらしいです。
今回はVueでイケてるインクリメンタルサーチのコンポーネントを作ります。
環境
- Vue 2.6
- Vuetify 1.3
- VComboboxを拡張します。https://vuetifyjs.com/ja/components/combobox
- Vuetify2.xを使用する場合、破壊的な変更により動かない場合があるためご注意ください
サンプル
今回実装するコンポーネントのサンプルです。
このサンプルではQiitaのAPIで、Qiitaに投稿された記事を記事名でインクリメンタルサーチします。
上が単一選択で下が複数選択です
全体のソースコードはGitHubにおいています
https://github.com/koushisa/incremental-search-sample
以下は解説です。
ソースコード
コンポーネントを使用している画面です。
<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>
こちらはインクリメンタルサーチのコンポーネントです
<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は個人的に使いやすいので気に入ってますが、
公式のドキュメントは若干不親切な感じはありますね。。。
使える機能は多いので、うまく付き合えるとかなりの価値を発揮できそうです。