diff --git a/src/components/ComboBox.vue b/src/components/ComboBox.vue index c75f0f4f4ce04e071ebe39d6c5c340ee35ee179d..f161011571e40c9bfe9b05cd3081beb4f335d7ef 100644 --- a/src/components/ComboBox.vue +++ b/src/components/ComboBox.vue @@ -22,32 +22,39 @@ {{ label }} </label> - <input - v-if="!disabled" - :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="[&:not(.form-control)]: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" - /> + <div class="tw-inline-flex tw-items-center tw-gap-1 tw-order-last"> + <input + v-if="!disabled" + :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="[&:not(.form-control)]:tw-text-inherit" + :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" + @keydown.enter.prevent.stop="selectChoice(choices[activeIndex])" + @keydown.delete="maybeRemoveChoice" + /> + <icon-gg-spinner + class="tw-animate-spin" + :class="{ 'tw-invisible': !isResolvingChoice }" + role="presentation" + /> + </div> <slot name="selected" :value="modelValue" :deselect="selectChoice" :is-open="isOpen" /> @@ -136,6 +143,7 @@ export type ComboBoxProps<T> = { disabled?: boolean noDataLabel?: string getKey?: (obj: T) => string | number + resolveChoice?: (obj: T | null | undefined, searchTerm: string) => Promise<T | null> | T | null closeOnSelect?: boolean inputClass?: unknown inputContainerClass?: unknown @@ -153,7 +161,7 @@ export type ComboBoxProps<T> = { <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' +import { clamp, useAsyncFunction, useId } from '@/util' defineSlots<{ default(props: { @@ -193,6 +201,7 @@ const props = withDefaults(defineProps<ComboBoxProps<T>>(), { throw new TypeError('You need to define a custom getKey function for your ComboBox object') } }, + resolveChoice: (obj: T | null | undefined) => obj ?? null, closeOnSelect: true, inputClass: undefined, inputContainerClass: undefined, @@ -264,11 +273,24 @@ function ensureOpen(event: Event) { } } -function selectChoice(choice: T | null) { - if (!Array.isArray(modelValue.value) || choice === null) { +const { fn: _resolveChoice, isProcessing: isResolvingChoice } = useAsyncFunction( + async (choice: T | null | undefined, term: string) => { + return await props.resolveChoice(choice, term) + }, +) + +async function selectChoice(choice: T | null) { + try { + choice = await _resolveChoice(choice, query.value.trim()) + } catch (e) { + console.error('Could not resolve choice', choice) + } + + if (Array.isArray(modelValue.value) && choice === null) return + if (!Array.isArray(modelValue.value)) { modelValue.value = choice } else { - const objIdentity = props.getKey(choice) + const objIdentity = props.getKey(choice as T) const dataCopy = Array.from(modelValue.value) let isHandled = false @@ -280,7 +302,7 @@ function selectChoice(choice: T | null) { } if (!isHandled) { - dataCopy.push(choice) + dataCopy.push(choice as T) inputEl?.value?.focus?.() } @@ -288,7 +310,7 @@ function selectChoice(choice: T | null) { } if (props.closeOnSelect) { - nextTick(close) + void nextTick(close) } else if (choice !== null) { query.value = '' }