選択肢が少ない場合はセレクトボックス(v-select)で良いのですが、数が多い場合はサジェスト(suggest)して選択させた方が良いですよね。
候補はいくつかありますが、最終的にv-autocompleteを使用する事にしました。
ただ、元の値を復元するのに手間取ったり、無駄なAPIリクエストが走ったりと。。。
作り込んでみたので、メモしておきます。
こんな感じになりました。
候補
vue-simple-suggest, vue-autosuggest, vue-cool-select
有名どころだと思います。この中ではvue-cool-selectが一番良かった。
入力中にフォーカスが外れると元の値に戻るとか。
ただ、他もですが、Vuetifyに入れているので当然かもですが、デザインが合わなかったり、
darkテーマだと文字が読めなかったりするので、デザイン調整も必要となります。
HTML5のautocomplete属性
iPhoneでは動かないという情報(古いOSだけかも)があったり、
PCだと直下にリスト表示されるのに対して、
Androidでは下に候補が横並びで出たりとUIの一貫性がなかった。
v-autocomplete
Vuetifyを使っているなら、素直にこれ使う冪ですね。もっと早く気付けば良かった。
デザインも用意されているので作り込む必要がない。
入力中にフォーカスが外れると元の値に戻ったり、カスタマイズも色々できる!
コードと説明
Nuxt Bridge + Vuetifyが動く前提です。
そのまま動くようにAPIリクエストはせずに値を設定しています。
ここだけ直せば実用にも耐え得るのではないかと。
pages/test.vue
<template> <div> <Suggest v-model="user" item-text="name" item-value="code" placeholder="ユーザー名を入力" :disabled="disabled" :error-messages="error === '' ? null : [error]" @input="waiting = false" > <!-- 選択項目 --> <template #selection="{ item }"> <v-avatar size="24px" class="mr-1"> <v-img :src="item.image_url" /> </v-avatar> {{ item.name }} </template> <!-- サジェスト項目 --> <template #item="{ item }"> <v-avatar size="24px" class="mr-1"> <v-img :src="item.image_url" /> </v-avatar> {{ item.name }} </template> </Suggest> <!-- TODO: 削除(デバッグ用) --> <div class="mt-4">user: {{ user }}</div> <div>waiting: {{ waiting }}</div> <div class="d-flex mt-2"> <v-checkbox v-model="disabled" label="無効" dense hide-details /> <v-text-field v-model="error" label="エラーメッセージ" dense outlined hide-details class="ml-4" /> </div> </div> </template> <script> import Suggest from '~/components/Suggest.vue' export default { components: { Suggest }, data () { return { waiting: true, //user: null, //user: { code: 'code1', name: 'name1', image_url: 'https://api.nightonly.com/images/user/mini_noimage.jpg' }, //user: { code: 'code2', name: 'namex', image_url: 'https://api.nightonly.com/images/space/mini_noimage.jpg' }, //user: { code: 'code3', name: 'namex', image_url: 'https://api.nightonly.com/images/user/mini_noimage.jpg' }, user: { code: 'code4', name: 'name4', image_url: 'https://api.nightonly.com/images/space/mini_noimage.jpg' }, disabled: false, error: '' } } } </script>
components/Suggest.vue
<template> <div> <v-autocomplete v-model="model" :items="items[searchKey]" :loading="loading" :search-input.sync="search" :item-text="itemText" :item-value="itemValue" no-filter return-object clearable :placeholder="placeholder" :no-data-text="loading ? '' : '候補が見つかりません。'" dense outlined hide-details="auto" :disabled="disabled" :error-messages="errorMessages" @mousedown="updateItems('mousedown', { reset: false, run: model == null })" @click:clear="updateItems('click:clear', { reset: true, run: true })" @change="updateItems('change', { reset: model == null, run: false })" @blur="updateItems('blur', { reset: model == null, run: false })" > <!-- NOTE: return-objectでもitem-value(ユニークな値)は必要。無いとno-filterでもitem-textが重複する値が表示されない @mousedownは初回に全件取得する為 @click:clear(ボタン)も全件取得する為 @change(キー入力で削除)は未選択の場合のみ全件取得する為 @blur(入力を抜けた時)は入力後、未選択の場合でもsearchに値が残る為、未入力なのに絞り込まれた結果が表示される為 --> <!-- 選択項目 --> <template #selection="{ item }"> <v-chip :close="!disabled" @click:close="model = null; updateItems('click:close', { reset: true, run: true })"> <!-- NOTE: @click:close(ボタン)も全件取得する為 --> <slot name="selection" :item="item">{{ item[itemText] }}</slot> </v-chip> </template> <!-- サジェスト項目 --> <template #item="{ item }"> <slot name="item" :item="item">{{ item[itemText] }}</slot> </template> <!-- 件数表示 --><!-- NOTE: UI的にセレクトボックスと勘違いしやすい為、続きがある事を明示 --> <template #append-item> <slot name="append-item" :pages="pages[searchKey]" :items="items[searchKey]"> <span v-if="pages[searchKey] != null && items[searchKey] != null && items[searchKey].length > 0" class="d-flex justify-end mt-2 mr-4"> {{ pages[searchKey].total_count }}件中 1-{{ items[searchKey].length }}件を表示 </span> </slot> </template> </v-autocomplete> <!-- TODO: 削除(デバッグ用) --> <div class="mt-4">model: {{ model }}</div> <div class="mt-2">search: {{ search }}</div> <div>pages[search]: {{ pages[searchKey] }}</div> <div>items[search]: {{ items[searchKey] }}</div> <div class="mt-2">loading: {{ loading }}</div> <div>skip: {{ skip }}</div> </div> </template> <script> export default { props: { value: { // NOTE: v-modeを受け取る type: Object, default: null }, itemText: { type: String, default: 'text' }, itemValue: { type: String, default: 'value' }, placeholder: { type: String, default: null }, disabled: { type: Boolean, default: true }, errorMessages: { type: Array, default: null } }, data () { return { model: this.value, pages: {}, // NOTE: APIから取得した場合のみセットして、キャッシュ存在チェックに使う items: (this.value == null) ? {} : { [this.value[this.itemText]]: [this.value] }, // NOTE: 候補に一致するものが存在しないと表示されない search: (this.value == null) ? null : this.value[this.itemText], // NOTE: nullにするとwatchが呼ばれないようになる loading: false, skip: false } }, computed: { searchKey () { return this.search || '' } }, watch: { model (value) { if (this.disabled) { return } this.$emit('update:value', value) // NOTE: 呼び出し元のv-modelの値を更新する this.$emit('input', value) // NOTE: 呼び出し元に変更を通知する }, search (text) { if (this.model != null && text == null) { // NOTE: 初期値を設定してもnullになり、選択項目が表示されない為 console.log('...update', this.model[this.itemText]) this.skip = true this.search = this.model[this.itemText] return } if (this.disabled) { return } this.updateItems('watch') } }, methods: { updateItems (called, option = { reset: false, run: true }) { if (this.disabled) { return } console.log('updateItems', called, option, this.skip, this.search, this.model, this.loading) if (this.skip) { this.skip = false console.log('...Skip(skip)') return } if (option.reset) { this.search = null } if (!option.run || this.loading) { console.log('...Skip(!run or loading)') return } const text = this.searchKey if (this.pages[text] != null) { // NOTE: APIレスポンスのみキャッシュを使用する為、itemsではなくpagesで判定 console.log('...Skip(cache)') return } this.loading = true // TODO: 実際はAPIで取得する const allItems = [ { code: 'code1', name: 'name1', image_url: 'https://api.nightonly.com/images/user/mini_noimage.jpg' }, { code: 'code2', name: 'namex', image_url: 'https://api.nightonly.com/images/space/mini_noimage.jpg' }, { code: 'code3', name: 'namex', image_url: 'https://api.nightonly.com/images/user/mini_noimage.jpg' }, { code: 'code4', name: 'name4', image_url: 'https://api.nightonly.com/images/space/mini_noimage.jpg' } ] if (this.search == null || ['', 'n', 'na', 'nam', 'name'].includes(this.search.toLowerCase())) { this.pages[text] = { total_count: 4, current_page: 1, total_pages: 2, limit_value: 3 } this.items[text] = allItems.slice(0, 3) } else if (this.search.toLowerCase() === 'name1') { this.pages[text] = { total_count: 1, current_page: 1, total_pages: 1, limit_value: 3 } this.items[text] = allItems.slice(0, 1) } else if (this.search.toLowerCase() === 'namex') { this.pages[text] = { total_count: 2, current_page: 1, total_pages: 1, limit_value: 3 } this.items[text] = allItems.slice(1, 3) } else if (this.search.toLowerCase() === 'name4') { this.pages[text] = { total_count: 1, current_page: 1, total_pages: 1, limit_value: 3 } this.items[text] = allItems.slice(3, 4) } else { this.pages[text] = { total_count: 0, current_page: 1, total_pages: 0, limit_value: 3 } this.items[text] = [] } this.loading = false } } } </script>
7/9更新: v-model(:value.syncと同じような動きになる)に変更して、
より実用的になるようにdisabledとerror-messagesも追加してみました。
これをベースに忘れん坊に機能追加しました。
コミット内容
https://dev.azure.com/nightonly/nightonly-app/_git/nightonly-nuxt/commit/a5b44de477254792559d43e030090ac1213a97a6