<template> <ComboBox v-model="modelValue" v-bind="props" :choices="filteredChoices" :close-on-select="props.closeOnSelect ?? !Array.isArray(modelValue)" input-container-class="tw-flex tw-flex-wrap tw-p-2 tw-gap-2 tw-items-baseline tw-w-full form-control tw-h-auto tw-min-h-[46px]" input-class="tw-border-none tw-px-1 tw-w-[100px] focus:tw-shadow-none focus:tw-outline-none focus:tw-ring-0" @search="searchQuery = $event" > <template #default="{ choice, ...attrs }"> <slot :choice="choice as T" v-bind="attrs"> <li v-bind="attrs"> {{ (choice as T).name }} </li> </slot> </template> <template #selected="{ value, deselect, isOpen }"> <slot name="selected" :value="value as (T | T[] | null)" :deselect="deselect" :is-open="isOpen" > <template v-if="Array.isArray(value)"> <Tag v-for="(item, index) in value" :key="index" :label="item.name" removable @remove="deselect(item)" /> </template> <template v-else> <p v-if="value">{{ (value as T).name }}</p> </template> </slot> </template> </ComboBox> </template> <script lang="ts"> import type { ComboBoxProps } from '@/components/ComboBox.vue' export type ComboBoxSimpleProps<T> = Omit<ComboBoxProps<T>, 'choices'> & { choices?: T[] searchProvider?: (query: string, signal: AbortSignal) => Promise<T[]> } </script> <script setup lang="ts" generic="T extends { id: ID, name?: string }"> import { ID } from '@rokoli/bnb/drf' import { computedAsync } from '@vueuse/core' import { computed, ref, toValue } from 'vue' import { computedDebounced, matchesSearch } from '@/util' import Tag from './generic/Tag.vue' import ComboBox from './ComboBox.vue' defineOptions({ compatConfig: { MODE: 3 }, }) defineSlots<{ default( props: Record<string, unknown> & { id: string choice: T index: number activeIndex: number }, ): unknown selected(props: { value: null | T | T[] deselect: (choice: null | T) => void isOpen: boolean }): unknown }>() const modelValue = defineModel<null | T | T[]>({ required: true }) const props = defineProps<ComboBoxSimpleProps<T>>() const searchQuery = ref('') const debouncedSearchQuery = computedDebounced(searchQuery, (t) => (t.trim() ? 0.3 : 0)) const selectedIds = computed(() => { if (Array.isArray(modelValue.value)) { return modelValue.value.map((item) => item.id) } else if (modelValue.value !== null) { return [modelValue.value.id] } else { return [] } }) let abortController: AbortController | null = null const searchedChoices = computedAsync( async () => { if (props.choices) { const query = toValue(searchQuery) return props.choices.filter(({ name }) => matchesSearch(name ?? '', query)) } else if (props.searchProvider) { const query = toValue(debouncedSearchQuery) if (abortController) abortController.abort() abortController = new AbortController() try { return await props.searchProvider(query, abortController.signal) } finally { abortController = null } } else { return [] } }, props.choices ?? [], { lazy: false }, ) const filteredChoices = computed(() => searchedChoices.value.filter((choice) => !selectedIds.value.includes(choice.id)), ) </script>