<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" > <slot name="selected" :object="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> </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 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: '', 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>