Newer
Older
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">
<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: 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()
}
})
if (props.choices.length > 0 || props.noDataLabel || slots.noData) {
isOpen.value = true
inputEl.value?.focus?.()
emit('open')
}
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 = ''
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>