選択肢が少ない場合はセレクトボックス(v-select)で良いのですが、数が多い場合はサジェスト(suggest)して選択させた方が良いですよね。
候補はいくつかありますが、最終的にv-autocompleteを使用する事にしました。
ただ、元の値を復元するのに手間取ったり、無駄なAPIリクエストが走ったりと。。。
作り込んでみたので、メモしておきます。
(Vuetify3)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