Skip to content
Snippets Groups Projects
ComboBoxSimple.vue 3.33 KiB
Newer Older
<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, ...attributes }">
      <slot :choice="choice" v-bind="attributes">
        <li v-bind="attributes">
          {{ choice.name }}
        </li>
      </slot>
    </template>

    <template #selected="{ value, deselect, isOpen }">
      <slot name="selected" :value="value" :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.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)),