Skip to content
Snippets Groups Projects
ComboBox.vue 8.71 KiB
Newer Older
  • Learn to ignore specific revisions
  • <template>
    
      <div
        ref="rootEl"
    
        class="tw-relative tw-cursor-text group"
    
        @keyup.esc="close"
        @keydown.esc.stop.prevent
        @click="inputEl?.focus?.()"
      >
    
        <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="labelClass">
    
            {{ label }}
          </label>
    
          <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-text-inherit tw-order-last"
    
            :class="inputClass"
            @keydown.up.prevent
            @keydown.down.prevent
            @keydown.home.prevent
            @keydown.end.prevent
            @keyup="ensureOpen"
    
            @keyup.down.prevent.stop="activeIndex += 1"
            @keyup.up.prevent.stop="activeIndex -= 1"
            @keyup.home.prevent.stop="activeIndex = 0"
            @keyup.end.prevent.stop="activeIndex = lastChoiceIndex"
    
            @keyup.enter.prevent.stop="selectChoice(choices[activeIndex])"
    
            @keydown.delete="maybeRemoveChoice"
    
          <slot name="selected" :value="modelValue" :deselect="selectChoice" :is-open="isOpen" />
    
    
          <kbd
            v-if="keyboardShortcut && keyboardShortcutLabel !== undefined && !isTouch"
            v-show="!isOpen"
            :title="keyboardShortcutLabel"
    
            class="tw-absolute tw-pointer-events-none tw-p-2 tw-text-xs tw-leading-none"
            :class="keyboardShortcutClass"
    
          >
            <template v-for="(token, index) in keyboardShortcutLabel.split(' ')" :key="index">
              <span v-if="token !== '+'">{{ token }}</span>
              <span v-else>&nbsp;</span>
            </template>
          </kbd>
    
        </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 && (choices.length > 0 || noDataLabel || slots.noData)"
    
            :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-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"
    
                :aria-activedescendant="activeResultId"
    
              >
                <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': true,
    
                      'tw-bg-aura-primary tw-text-white': activeIndex === index,
                    }"
                    :choice="choice"
                    :index="index"
                    :active-index="activeIndex"
    
                    @click.stop.prevent="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">
    import type { Ref } from 'vue'
    
    export type ComboBoxProps<T> = {
      choices: T[]
      keyboardShortcut?: Ref<boolean> | undefined
      keyboardShortcutLabel?: string
      label?: string
      noDataLabel?: string
      getKey?: (obj: T) => string | number
      closeOnSelect?: boolean
      inputClass?: unknown
      inputContainerClass?: unknown
    
      keyboardShortcutClass?: unknown
      labelClass?: unknown
    
      drawerClass?: unknown
      // TODO: this fixes errors with arbitrary attributes (like data-*), but
      //       is just a workaround for https://github.com/vuejs/core/issues/8372
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      [attrs: string]: any
    }
    </script>
    
    
    <script lang="ts" setup generic="T">
    
    import { computed, nextTick, ref, useSlots, watch, watchEffect } from 'vue'
    
    import { onClickOutside, useFocusWithin, useMediaQuery, whenever } from '@vueuse/core'
    import { clamp, useId } from '@/util'
    
    defineOptions({
      compatConfig: { MODE: 3 },
      ATTR_FALSE_VALUE: false,
    })
    
    defineSlots<{
      default(
        props: Record<string, unknown> & {
          id: string
    
          index: number
          activeIndex: number
        },
      ): unknown
    
      selected(props: {
        value: null | T | T[]
        deselect: (choice: null | T) => void
        isOpen: boolean
      }): unknown
    
      filter?(props: { resultsId: string }): any
      noData?(props: Record<string, never>): any
      pre?(props: Record<string, never>): any
    }>()
    
    const modelValue = defineModel<null | T | T[]>({ required: true })
    const props = withDefaults(defineProps<ComboBoxProps<T>>(), {
      keyboardShortcut: undefined,
      keyboardShortcutLabel: '',
      label: '',
      noDataLabel: '',
      getKey: (obj: T) => {
        if (
          typeof obj === 'object' &&
          obj !== null &&
          'id' in obj &&
          (typeof obj.id === 'string' || typeof obj.id === 'number')
        ) {
          return obj.id
        } else if (typeof obj === 'string' || typeof obj === 'number') {
          return obj
        } else {
          throw new TypeError('You need to define a custom getKey function for your ComboBox object')
        }
    
      closeOnSelect: true,
      inputClass: undefined,
      inputContainerClass: undefined,
    
      keyboardShortcutClass: undefined,
    
      drawerClass: undefined,
    })
    
    const emit = defineEmits<{
      (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 lastChoiceIndex = computed(() => Math.max(0, props.choices.length - 1))
    
    const isOpen = ref(false)
    const isTouch = useMediaQuery('screen and (pointer: coarse)')
    
    const { focused: hasFocus } = useFocusWithin(rootEl)
    
    const inputId = useId('combobox-input')
    const drawerId = useId('combobox-drawer')
    const resultsId = useId('combobox-results')
    const activeResultId = computed(() => `${resultsId.value}-option-${activeIndex.value}`)
    
    
    watchEffect(() => {
      activeIndex.value = clamp(activeIndex.value, 0, lastChoiceIndex.value)
    })
    
    watch(hasFocus, (hasFocus, hadFocus) => {
      if (hasFocus && !hadFocus) {
        open()
      } else if (!hasFocus && hadFocus) {
        close()
      }
    })
    
    
    function open() {
    
      if (props.choices.length > 0 || props.noDataLabel || slots.noData) {
        isOpen.value = true
        inputEl.value?.focus?.()
        emit('open')
      }
    
    function close() {
    
      isOpen.value = false
      query.value = ''
      activeIndex.value = 0
      emit('close')
    }
    
    function ensureOpen(event: Event) {
      if (!isOpen.value) {
        event.preventDefault()
        event.stopImmediatePropagation()
        nextTick(open)
      }
    }
    
    
    function selectChoice(choice: T | null) {
      if (!Array.isArray(modelValue.value) || choice === null) {
        modelValue.value = choice
    
      } else {
        const objIdentity = props.getKey(choice)
    
        const dataCopy = Array.from(modelValue.value)
    
        let isHandled = false
    
        for (const [index, item] of dataCopy.entries()) {
          if (props.getKey(item) === objIdentity) {
            dataCopy.splice(index, 1)
            isHandled = true
          }
        }
    
        if (!isHandled) {
          dataCopy.push(choice)
          inputEl?.value?.focus?.()
        }
    
    
        modelValue.value = dataCopy
    
      }
    
      if (props.closeOnSelect) {
        nextTick(close)
    
      } else if (choice !== null) {
        query.value = ''
    
    function maybeRemoveChoice() {
    
      if (query.value === '' && Array.isArray(modelValue.value)) {
        const newValue = Array.from(modelValue.value)
    
        newValue.splice(newValue.length - 1, 1)
    
        modelValue.value = newValue
    
    }
    
    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>