v-autocompleteをVuetify2からVuetify3に移行してみました。
選択肢に選択している値が無くても表示されるようなったり、
イベントが整理され、mousedownやchange、blurを使って頑張らなくても、
update:menuが追加されたりと、使いやすくなっています。
Vuetify2 → Vuetify3
(Vuetify2)v-autocomplete:無駄なAPIリスクエストをせずにサジェストを表示する
↓
※color="primary"
追加すれば青色にできます。
avatarはデフォルトのを使うようにして、コードも表示するようにしてみました。
Options APIのまま移行
※item-text, item-value, placeholderは直指定に変更しています。
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"
+ @update:model-value="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-checkbox v-model="disabled" color="primary" label="無効" density="compact" hide-details />
- <v-text-field v-model="error" label="エラーメッセージ" dense outlined hide-details class="ml-4" />
+ <v-text-field v-model="error" label="エラーメッセージ" density="compact" variant="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"
+ v-model="syncModelValue"
+ v-model:search="search"
:items="items[searchKey]"
- :loading="loading"
+ :loading="loading[searchKey]"
- :search-input.sync="search"
- :item-text="itemText"
+ item-title="name"
- :item-value="itemValue"
+ item-value="code"
no-filter
return-object
+ chips
clearable
- :placeholder="placeholder"
+ placeholder="ユーザー名を入力"
- :no-data-text="loading ? '' : '候補が見つかりません。'"
+ :no-data-text="loading[searchKey] ? '' : '候補が見つかりません。'"
- dense
+ density="compact"
- outlined
+ variant="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 })"
+ @update:search="updateItems(null, searchKey)"
+ @update:menu="updateItems($event, searchKey)"
>
<!-- NOTE:
+ eslint-plugin-vuetifyで自動修正される[v-model:search-input="search"]だと発火しない。searchは初期値で開始、@update:searchで検索
return-objectでもitem-value(ユニークな値)は必要。無いとno-filterでもitem-textが重複する値が表示されない
- @mousedownは初回に全件取得する為
- @click:clear(ボタン)も全件取得する為
- @change(キー入力で削除)は未選択の場合のみ全件取得する為
- @blur(入力を抜けた時)は入力後、未選択の場合でもsearchに値が残る為、未入力なのに絞り込まれた結果が表示される為
-->
<!-- 選択項目 -->
- <template #selection="{ item }">
+ <template #chip="{ props, item }">
<v-chip
+ v-bind="props"
+ :prepend-avatar="item.raw.image_url"
+ :text="item.raw.name"
- :close="!disabled"
+ :closable="!disabled"
- @click:close="model = null; updateItems('click:close', { reset: true, run: true })"
+ @click:close="syncModelValue = null"
- >
- <!-- NOTE: @click:close(ボタン)も全件取得する為 -->
- <slot name="selection" :item="item">{{ item[itemText] }}</slot>
- </v-chip>
+ />
</template>
<!-- サジェスト項目 -->
- <template #item="{ item }">
+ <template #item="{ props, item }">
- <slot name="item" :item="item">{{ item[itemText] }}</slot>
+ <v-list-item
+ v-bind="props"
+ :prepend-avatar="item.raw.image_url"
+ :title="item.raw.name"
+ :subtitle="item.raw.code"
+ />
</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-4">modelValue: {{ syncModelValue }}</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 class="mt-2">loading[search]: {{ loading[searchKey] }}</div>
- <div>skip: {{ skip }}</div>
- </div>
</template>
<script>
export default {
props: {
- value: { // NOTE: v-modeを受け取る
+ modelValue: {
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
}
},
+ emits: ['update:modelValue'],
data () {
return {
- model: this.value,
pages: {}, // NOTE: APIから取得した場合のみセットして、キャッシュ存在チェックに使う
- items: (this.value == null) ? {} : { [this.value[this.itemText]]: [this.value] }, // NOTE: 候補に一致するものが存在しないと表示されない
+ items: {},
- search: (this.value == null) ? null : this.value[this.itemText], // NOTE: nullにするとwatchが呼ばれないようになる
+ search: null,
- loading: false,
+ loading: {}
- skip: false
}
},
computed: {
+ syncModelValue: {
+ get () {
+ return this.modelValue
+ },
+ set (value) {
+ this.$emit('update:modelValue', value)
+ }
+ },
+
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 }) {
+ updateItems (menuOpen, text) {
if (this.disabled) { return }
- console.log('updateItems', called, option, this.skip, this.search, this.model, this.loading)
+ console.log('updateItems', menuOpen, text, this.loading[text], this.items[text] != null)
- if (this.skip) {
+ if (menuOpen === false || this.loading[text] || this.items[text] != null) {
- this.skip = false
- console.log('...Skip(skip)')
+ console.log('...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
+ this.loading[text] = 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
+ this.loading[text] = false
}
}
}
</script>
Composition API+TypeScriptに書き換え
components/Suggest.vue
<template>
<v-autocomplete
v-model="syncModelValue"
v-model:search="search"
:items="items[searchKey]"
:loading="loading[searchKey]"
item-title="name"
item-value="code"
no-filter
return-object
chips
clearable
placeholder="ユーザー名を入力"
:no-data-text="loading[searchKey] ? '' : '候補が見つかりません。'"
density="compact"
variant="outlined"
hide-details="auto"
:disabled="disabled"
:error-messages="errorMessages"
@update:search="updateItems(null, searchKey)"
@update:menu="updateItems($event, searchKey)"
>
<!-- NOTE:
eslint-plugin-vuetifyで自動修正される[v-model:search-input="search"]だと発火しない。searchは初期値で開始、@update:searchで検索
return-objectでもitem-value(ユニークな値)は必要。無いとno-filterでもitem-textが重複する値が表示されない
-->
<!-- 選択項目 -->
<template #chip="{ props, item }">
<v-chip
v-bind="props"
:prepend-avatar="item.raw.image_url"
:text="item.raw.name"
:closable="!disabled"
@click:close="syncModelValue = null"
/>
</template>
<!-- サジェスト項目 -->
<template #item="{ props, item }">
<v-list-item
v-bind="props"
:prepend-avatar="item.raw.image_url"
:title="item.raw.name"
:subtitle="item.raw.code"
/>
</template>
<!-- 件数表示 --><!-- NOTE: UI的にセレクトボックスと勘違いしやすい為、続きがある事を明示 -->
<template #append-item>
<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>
</template>
</v-autocomplete>
<!-- TODO: 削除(デバッグ用) -->
<div class="mt-4">modelValue: {{ syncModelValue }}</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[search]: {{ loading[searchKey] }}</div>
</template>
<script setup lang="ts">
const $props = defineProps({
modelValue: {
type: Object,
default: null
},
disabled: {
type: Boolean,
default: true
},
errorMessages: {
type: Array,
default: null
}
})
const $emit = defineEmits(['update:modelValue'])
const syncModelValue = computed({
get: () => $props.modelValue,
set: (value: object) => $emit('update:modelValue', value)
})
const pages = ref({}) // NOTE: APIから取得した場合のみセットして、キャッシュ存在チェックに使う
const items = ref({})
const search = ref<any>(null)
const loading = ref<any>({})
const searchKey = computed(() => search.value || '')
// サジェスト項目更新
function updateItems (menuOpen: boolean | null, text: string) {
if ($props.disabled) { return }
console.log('updateItems', menuOpen, text, loading.value[text], items.value[text] != null)
if (menuOpen === false || loading.value[text] || items.value[text] != null) {
console.log('...Skip')
return
}
loading.value[text] = 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 (search.value == null || ['', 'n', 'na', 'nam', 'name'].includes(search.value.toLowerCase())) {
pages.value[text] = { total_count: 4, current_page: 1, total_pages: 2, limit_value: 3 }
items.value[text] = allItems.slice(0, 3)
} else if (search.value.toLowerCase() === 'name1') {
pages.value[text] = { total_count: 1, current_page: 1, total_pages: 1, limit_value: 3 }
items.value[text] = allItems.slice(0, 1)
} else if (search.value.toLowerCase() === 'namex') {
pages.value[text] = { total_count: 2, current_page: 1, total_pages: 1, limit_value: 3 }
items.value[text] = allItems.slice(1, 3)
} else if (search.value.toLowerCase() === 'name4') {
pages.value[text] = { total_count: 1, current_page: 1, total_pages: 1, limit_value: 3 }
items.value[text] = allItems.slice(3, 4)
} else {
pages.value[text] = { total_count: 0, current_page: 1, total_pages: 0, limit_value: 3 }
items.value[text] = []
}
loading.value[text] = false
}
</script>