<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> </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: { id: string choice: T index: number activeIndex: number [k: string]: unknown }): 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>