Skip to content
Snippets Groups Projects
ComboBox.vue 7.31 KiB
Newer Older
  • Learn to ignore specific revisions
  • <template>
      <div ref="rootEl" class="tw-relative" @keyup.esc="close">
        <div
          class="tw-relative"
          :class="inputContainerClass"
          aria-haspopup="listbox"
          :aria-expanded="isOpen"
          :aria-controls="resultsId"
          tabindex="-1"
        >
          <slot name="pre" />
          <label
            v-if="label"
            :for="inputId"
            class="tw-absolute tw-top-2 tw-left-8 tw-text-sm tw-font-bold tw-pointer-events-none"
          >
            {{ label }}
          </label>
          <span
            v-if="!isOpen && modelValue"
            class="tw-absolute tw-pointer-events-none tw-top-7 tw-left-8"
          >
            {{ getDisplayName(modelValue) }}
          </span>
          <kbd
            v-if="keyboardShortcut && keyboardShortcutLabel !== undefined && !isTouch"
            v-show="!isOpen"
            :title="keyboardShortcutLabel"
            class="tw-absolute tw-pointer-events-none tw-top-4 tw-right-8 tw-bg-black/20 tw-opacity-60 tw-p-2 tw-text-xs tw-rounded-lg tw-leading-none"
          >
            <template v-for="(token, index) in keyboardShortcutLabel.split(' ')" :key="index">
              <span v-if="token !== '+'">{{ token }}</span>
              <span v-else>&nbsp;</span>
            </template>
          </kbd>
          <input
            :id="inputId"
            ref="inputEl"
            v-model="query"
            aria-autocomplete="list"
            :aria-expanded="isOpen"
            :aria-controls="resultsId"
            :aria-activedescendant="activeResultId"
            role="combobox"
            tabindex="0"
            type="text"
            class="tw-pt-6 tw-pb-2 tw-px-8 md:tw-w-80 tw-text-inherit"
            :class="inputClass"
            @keydown.up.prevent
            @keydown.down.prevent
            @keydown.home.prevent
            @keydown.end.prevent
            @keyup="ensureOpen"
            @keyup.down.prevent.stop="updateIndex(1)"
            @keyup.up.prevent.stop="updateIndex(-1)"
            @keyup.home.prevent.stop="updateIndex(Number.NEGATIVE_INFINITY)"
            @keyup.end.prevent.stop="updateIndex(Number.POSITIVE_INFINITY)"
            @keyup.enter.prevent.stop="selectChoice(choices[activeIndex])"
            @focus="open"
          />
        </div>
    
        <Transition
          enter-active-class="tw-transition tw-duration-200 tw-ease-out"
          enter-from-class="tw-translate-y-1 tw-opacity-0"
          enter-to-class="tw-translate-y-0 tw-opacity-100"
          leave-active-class="tw-transition tw-duration-150 tw-ease-in"
          leave-from-class="tw-translate-y-0 tw-opacity-100"
          leave-to-class="tw-translate-y-1 tw-opacity-0"
        >
          <div
            v-if="isOpen"
            :id="drawerId"
            :class="[
              drawerClass,
              {
                'tw-fixed tw-left-2 tw-right-2 tw-z-20 tw-mt-2 tw-bg-white tw-shadow-2xl tw-rounded tw-flex tw-flex-col tw-overflow-hidden': true,
                'md:tw-absolute md:tw-top-full md:tw-left-auto md:tw-right-0 md:tw-w-fit md:tw-flex-row': true,
              },
            ]"
          >
            <div class="tw-order-2 tw-flex-none tw-p-6 tw-bg-gray-100 md:tw-w-80">
              <ul
                v-if="choices.length > 0"
                ref="choicesEl"
                role="listbox"
                class="tw-max-h-96 tw-overflow-y-auto tw-block tw-p-0 tw-m-0"
              >
                <template v-for="(choice, index) in choices" :key="getKey(choice)">
                  <slot
                    :id="`${resultsId}-option-${index}`"
                    role="option"
                    tabindex="-1"
                    class="tw-py-2 tw-px-4 tw-rounded tw-block tw-cursor-pointer"
                    :class="{
                      'tw-bg-aura-primary tw-text-white': activeIndex === index,
                    }"
                    :choice="choice"
                    :index="index"
                    :active-index="activeIndex"
                    @click="selectChoice(choice)"
                    @mouseenter="activeIndex = index"
                  />
                </template>
              </ul>
    
              <slot v-else name="noData">
                <p class="tw-font-bold">{{ noDataLabel }}</p>
              </slot>
            </div>
    
            <div v-if="slots.filter" class="tw-order-1 tw-p-6 tw-flex tw-flex-col tw-gap-3">
              <slot name="filter" :results-id="resultsId" />
            </div>
          </div>
        </Transition>
      </div>
    </template>
    
    <script lang="ts" setup>
    // TODO: implement proper typing once we upgrade to Vue 3.3.
    import { computed, nextTick, Ref, ref, useSlots, watchEffect } from 'vue'
    import { onClickOutside, useMediaQuery, whenever } from '@vueuse/core'
    import { useId } from '@/util'
    
    const props = withDefaults(
      defineProps<{
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        modelValue: any
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        choices: any[]
    
        keyboardShortcut?: Ref<boolean> | undefined
    
        keyboardShortcutLabel?: string
        label?: string
        noDataLabel?: string
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        getDisplayName?: (obj: any) => string
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        getKey?: (obj: any) => string | number
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        inputClass?: any
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        inputContainerClass?: any
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        drawerClass?: any
      }>(),
      {
        keyboardShortcut: undefined,
        keyboardShortcutLabel: '',
        label: '',
        noDataLabel: '',
        getDisplayName: (obj: { name: string }) => String(obj?.name),
        getKey: (obj: { id: string | number }) => obj.id,
        inputClass: undefined,
        inputContainerClass: undefined,
        drawerClass: undefined,
      },
    )
    const emit = defineEmits<{
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      (e: 'update:modelValue', value: any): void
      (e: 'search', value: string): void
      (e: 'open'): void
      (e: 'close'): void
    }>()
    const slots = useSlots()
    
    const query = ref('')
    const rootEl = ref<HTMLElement>()
    const inputEl = ref<HTMLInputElement>()
    const choicesEl = ref<HTMLUListElement>()
    const activeIndex = ref(0)
    const isOpen = ref(false)
    const isTouch = useMediaQuery('screen and (pointer: coarse)')
    const inputId = useId('combobox-input')
    const drawerId = useId('combobox-drawer')
    const resultsId = useId('combobox-results')
    const activeResultId = computed(() => `${resultsId.value}-option-${activeIndex.value}`)
    
    function open() {
      isOpen.value = true
      inputEl.value?.focus?.()
      emit('open')
    }
    
    function close(event?: Event) {
      if (event instanceof KeyboardEvent && isOpen.value) {
        event.stopImmediatePropagation()
        event.preventDefault()
      }
      isOpen.value = false
      query.value = ''
      activeIndex.value = 0
      emit('close')
    }
    
    function ensureOpen(event: Event) {
      if (!isOpen.value) {
        event.preventDefault()
        event.stopImmediatePropagation()
        nextTick(open)
      }
    }
    
    function updateIndex(modifier: number) {
      const newIndex = activeIndex.value + modifier
      activeIndex.value = Math.max(0, Math.min(newIndex, props.choices.length - 1))
    }
    
    function selectChoice(obj: unknown) {
      emit('update:modelValue', obj)
      nextTick(close)
    }
    
    watchEffect(() => {
      emit('search', query.value)
    })
    
    watchEffect(() => {
      const listItem = choicesEl.value?.children?.[activeIndex.value]
      listItem?.scrollIntoView?.({ behavior: 'smooth', block: 'nearest', inline: 'start' })
    })
    
    onClickOutside(rootEl, close)
    whenever(
      computed(() => props.keyboardShortcut?.value),
      open,
    )
    </script>
    
    <script lang="ts">
    export default {
      compatConfig: {
        MODE: 3,
        ATTR_FALSE_VALUE: false,
      },
    }
    </script>