Skip to content
Snippets Groups Projects
Commit 4f1901b6 authored by Konrad Mohrfeldt's avatar Konrad Mohrfeldt :koala:
Browse files

feat(ComboBox): add support for async choice transformations

This makes it possible to add arbitrary choices that may not be directly
assignable but can transformed into a valid choice.

This way we can add a "Create new" choice, create a new object once it’s
selected and return that newly created object as resolved choice.
parent a61f386e
No related branches found
No related tags found
No related merge requests found
...@@ -22,32 +22,39 @@ ...@@ -22,32 +22,39 @@
{{ label }} {{ label }}
</label> </label>
<input <div class="tw-inline-flex tw-items-center tw-gap-1 tw-order-last">
v-if="!disabled" <input
:id="inputId" v-if="!disabled"
ref="inputEl" :id="inputId"
v-model="query" ref="inputEl"
aria-autocomplete="list" v-model="query"
:aria-expanded="isOpen" aria-autocomplete="list"
:aria-controls="resultsId" :aria-expanded="isOpen"
:aria-activedescendant="activeResultId" :aria-controls="resultsId"
role="combobox" :aria-activedescendant="activeResultId"
tabindex="0" role="combobox"
type="text" tabindex="0"
class="[&:not(.form-control)]:tw-text-inherit tw-order-last" type="text"
:class="inputClass" class="[&:not(.form-control)]:tw-text-inherit"
@keydown.up.prevent :class="inputClass"
@keydown.down.prevent @keydown.up.prevent
@keydown.home.prevent @keydown.down.prevent
@keydown.end.prevent @keydown.home.prevent
@keyup="ensureOpen" @keydown.end.prevent
@keyup.down.prevent.stop="activeIndex += 1" @keyup="ensureOpen"
@keyup.up.prevent.stop="activeIndex -= 1" @keyup.down.prevent.stop="activeIndex += 1"
@keyup.home.prevent.stop="activeIndex = 0" @keyup.up.prevent.stop="activeIndex -= 1"
@keyup.end.prevent.stop="activeIndex = lastChoiceIndex" @keyup.home.prevent.stop="activeIndex = 0"
@keyup.enter.prevent.stop="selectChoice(choices[activeIndex])" @keyup.end.prevent.stop="activeIndex = lastChoiceIndex"
@keydown.delete="maybeRemoveChoice" @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" /> <slot name="selected" :value="modelValue" :deselect="selectChoice" :is-open="isOpen" />
...@@ -136,6 +143,7 @@ export type ComboBoxProps<T> = { ...@@ -136,6 +143,7 @@ export type ComboBoxProps<T> = {
disabled?: boolean disabled?: boolean
noDataLabel?: string noDataLabel?: string
getKey?: (obj: T) => string | number getKey?: (obj: T) => string | number
resolveChoice?: (obj: T | null | undefined, searchTerm: string) => Promise<T | null> | T | null
closeOnSelect?: boolean closeOnSelect?: boolean
inputClass?: unknown inputClass?: unknown
inputContainerClass?: unknown inputContainerClass?: unknown
...@@ -153,7 +161,7 @@ export type ComboBoxProps<T> = { ...@@ -153,7 +161,7 @@ export type ComboBoxProps<T> = {
<script lang="ts" setup generic="T"> <script lang="ts" setup generic="T">
import { computed, nextTick, ref, useSlots, watch, watchEffect } from 'vue' import { computed, nextTick, ref, useSlots, watch, watchEffect } from 'vue'
import { onClickOutside, useFocusWithin, useMediaQuery, whenever } from '@vueuse/core' import { onClickOutside, useFocusWithin, useMediaQuery, whenever } from '@vueuse/core'
import { clamp, useId } from '@/util' import { clamp, useAsyncFunction, useId } from '@/util'
defineSlots<{ defineSlots<{
default(props: { default(props: {
...@@ -193,6 +201,7 @@ const props = withDefaults(defineProps<ComboBoxProps<T>>(), { ...@@ -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') throw new TypeError('You need to define a custom getKey function for your ComboBox object')
} }
}, },
resolveChoice: (obj: T | null | undefined) => obj ?? null,
closeOnSelect: true, closeOnSelect: true,
inputClass: undefined, inputClass: undefined,
inputContainerClass: undefined, inputContainerClass: undefined,
...@@ -264,11 +273,24 @@ function ensureOpen(event: Event) { ...@@ -264,11 +273,24 @@ function ensureOpen(event: Event) {
} }
} }
function selectChoice(choice: T | null) { const { fn: _resolveChoice, isProcessing: isResolvingChoice } = useAsyncFunction(
if (!Array.isArray(modelValue.value) || choice === null) { 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 modelValue.value = choice
} else { } else {
const objIdentity = props.getKey(choice) const objIdentity = props.getKey(choice as T)
const dataCopy = Array.from(modelValue.value) const dataCopy = Array.from(modelValue.value)
let isHandled = false let isHandled = false
...@@ -280,7 +302,7 @@ function selectChoice(choice: T | null) { ...@@ -280,7 +302,7 @@ function selectChoice(choice: T | null) {
} }
if (!isHandled) { if (!isHandled) {
dataCopy.push(choice) dataCopy.push(choice as T)
inputEl?.value?.focus?.() inputEl?.value?.focus?.()
} }
...@@ -288,7 +310,7 @@ function selectChoice(choice: T | null) { ...@@ -288,7 +310,7 @@ function selectChoice(choice: T | null) {
} }
if (props.closeOnSelect) { if (props.closeOnSelect) {
nextTick(close) void nextTick(close)
} else if (choice !== null) { } else if (choice !== null) {
query.value = '' query.value = ''
} }
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment