Skip to content
Snippets Groups Projects
ComboBox.vue 8.71 KiB
Newer Older
<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>