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>

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です