210 lines
5.9 KiB
Vue
210 lines
5.9 KiB
Vue
|
<template>
|
||
|
<Combobox :modelValue="localValue" @update:modelValue="valueChanged" nullable>
|
||
|
<div class="relative mt-1">
|
||
|
<div
|
||
|
class="relative w-full cursor-default overflow-hidden rounded-md bg-white text-left focus:outline-none focus-visible:ring-2 sm:text-sm"
|
||
|
>
|
||
|
<ComboboxInput
|
||
|
:id="forId"
|
||
|
:class="inputClass"
|
||
|
:displayValue="(option: any) => getLabel(option)"
|
||
|
@change="query = $event.target.value"
|
||
|
/>
|
||
|
<ComboboxButton
|
||
|
class="absolute inset-y-0 right-0 flex items-center pr-2"
|
||
|
>
|
||
|
<ArrowPathIcon
|
||
|
class="h-5 w-5 animate-spin text-gray-400"
|
||
|
aria-hidden="true"
|
||
|
v-if="searching"
|
||
|
/>
|
||
|
<ChevronUpDownIcon class="h-5 w-5 text-gray-400" aria-hidden="true" />
|
||
|
</ComboboxButton>
|
||
|
</div>
|
||
|
<TransitionRoot
|
||
|
leave="transition ease-in duration-100"
|
||
|
leaveFrom="opacity-100"
|
||
|
leaveTo="opacity-0"
|
||
|
@after-leave="query = ''"
|
||
|
>
|
||
|
<ComboboxOptions
|
||
|
class="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
|
||
|
>
|
||
|
<div
|
||
|
v-if="options.length === 0 && (query !== '' || searching)"
|
||
|
class="relative cursor-default select-none py-2 px-4 text-gray-700"
|
||
|
>
|
||
|
<slot name="notfound" :query="query" :searching="searching">
|
||
|
{{ searching ? 'Loading..' : 'Nothing found.' }}
|
||
|
</slot>
|
||
|
</div>
|
||
|
|
||
|
<ComboboxOption
|
||
|
v-for="option in options"
|
||
|
as="template"
|
||
|
:key="option[bindValue]"
|
||
|
:value="option"
|
||
|
:disabled="option?.disabled"
|
||
|
v-slot="{ selected, active }"
|
||
|
>
|
||
|
<li :class="itemClass(active, selected)">
|
||
|
<slot
|
||
|
name="option"
|
||
|
:selected="selected"
|
||
|
:active="active"
|
||
|
:option="option"
|
||
|
>
|
||
|
<span
|
||
|
class="block truncate"
|
||
|
:class="{ 'font-medium': selected, 'font-normal': !selected }"
|
||
|
>
|
||
|
{{
|
||
|
typeof bindLabel === 'function'
|
||
|
? bindLabel(option)
|
||
|
: option[bindLabel]
|
||
|
}}
|
||
|
</span>
|
||
|
</slot>
|
||
|
<span
|
||
|
v-if="selected"
|
||
|
class="absolute inset-y-0 left-0 flex items-center pl-2 text-blue-600"
|
||
|
>
|
||
|
<CheckIcon class="h-5 w-5" aria-hidden="true" />
|
||
|
</span>
|
||
|
</li>
|
||
|
</ComboboxOption>
|
||
|
</ComboboxOptions>
|
||
|
</TransitionRoot>
|
||
|
</div>
|
||
|
</Combobox>
|
||
|
</template>
|
||
|
|
||
|
<script setup lang="ts">
|
||
|
import { ref, computed, watch, shallowRef } from 'vue';
|
||
|
import {
|
||
|
Combobox,
|
||
|
ComboboxInput,
|
||
|
ComboboxButton,
|
||
|
ComboboxOptions,
|
||
|
ComboboxOption,
|
||
|
TransitionRoot,
|
||
|
} from '@headlessui/vue';
|
||
|
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/vue/20/solid';
|
||
|
import deepUnref from '../../../utils/deep-unref';
|
||
|
import { ArrowPathIcon } from '@heroicons/vue/24/outline';
|
||
|
import { useDebounceFn } from '@vueuse/shared';
|
||
|
|
||
|
const props = withDefaults(
|
||
|
defineProps<{
|
||
|
forId?: string;
|
||
|
modelValue: any;
|
||
|
initialOptions?: any[];
|
||
|
searchFn?: (query: string) => Promise<any[]>;
|
||
|
bindValue?: string;
|
||
|
bindLabel?: string | ((obj: any) => string);
|
||
|
disabled?: boolean;
|
||
|
invalid?: boolean;
|
||
|
placeholder?: string;
|
||
|
}>(),
|
||
|
{
|
||
|
modelValue: null,
|
||
|
disabled: false,
|
||
|
invalid: false,
|
||
|
bindValue: 'id',
|
||
|
bindLabel: 'displayName',
|
||
|
}
|
||
|
);
|
||
|
|
||
|
if (!props.initialOptions && !props.searchFn) {
|
||
|
throw new Error(
|
||
|
'[Autocomplete] you must either pass initial props or a search function!'
|
||
|
);
|
||
|
}
|
||
|
|
||
|
const options = shallowRef<any[]>(props.initialOptions || []);
|
||
|
const query = ref('');
|
||
|
const searching = ref(false);
|
||
|
|
||
|
const inputClass = computed(() => {
|
||
|
return [
|
||
|
props.invalid
|
||
|
? 'border-red-300 focus:border-red-500 focus:ring-red-500'
|
||
|
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500',
|
||
|
`block w-full rounded-md shadow-sm sm:text-sm transition-colors duration-200`,
|
||
|
'py-2 pl-3 pr-10 text-left cursor-default h-[38px] border-[1px] focus:ring-1',
|
||
|
];
|
||
|
});
|
||
|
|
||
|
const itemClass = (active: boolean, selected: boolean) => {
|
||
|
const classList = ['relative cursor-default select-none py-2 pl-8 pr-4'];
|
||
|
if (selected) {
|
||
|
classList.push('text-blue-900');
|
||
|
}
|
||
|
|
||
|
if (active && selected) {
|
||
|
classList.push('bg-blue-200');
|
||
|
} else if (active || selected) {
|
||
|
classList.push('bg-blue-100');
|
||
|
} else {
|
||
|
classList.push('text-gray-900');
|
||
|
}
|
||
|
|
||
|
return classList;
|
||
|
};
|
||
|
|
||
|
const localValue = shallowRef(props.modelValue);
|
||
|
|
||
|
const emit = defineEmits<{
|
||
|
(e: 'update:modelValue', value: string | number | null): void;
|
||
|
}>();
|
||
|
|
||
|
const valueChanged = (option: any) => {
|
||
|
localValue.value = option;
|
||
|
emit('update:modelValue', deepUnref(localValue.value || null));
|
||
|
};
|
||
|
|
||
|
const searchFnDebounced = useDebounceFn((text: string) => {
|
||
|
searching.value = true;
|
||
|
props
|
||
|
.searchFn?.(text)
|
||
|
.then((response) => {
|
||
|
options.value = response;
|
||
|
})
|
||
|
.catch(console.error)
|
||
|
.finally(() => (searching.value = false));
|
||
|
}, 300);
|
||
|
|
||
|
const getLabel = (option: any) =>
|
||
|
option
|
||
|
? typeof props.bindLabel === 'function'
|
||
|
? props.bindLabel(option)
|
||
|
: option[props.bindLabel]
|
||
|
: '';
|
||
|
|
||
|
const localSearch = (text: string) => {
|
||
|
if (!props.initialOptions) return;
|
||
|
|
||
|
options.value =
|
||
|
text === ''
|
||
|
? props.initialOptions
|
||
|
: props.initialOptions.filter((option) =>
|
||
|
getLabel(option)
|
||
|
.toLowerCase()
|
||
|
.replace(/\s+/g, '')
|
||
|
.includes(text.toLowerCase().replace(/\s+/g, ''))
|
||
|
);
|
||
|
};
|
||
|
|
||
|
watch(
|
||
|
query,
|
||
|
(text) => {
|
||
|
if (props.searchFn) {
|
||
|
return searchFnDebounced(text);
|
||
|
}
|
||
|
|
||
|
localSearch(text);
|
||
|
},
|
||
|
{ immediate: true }
|
||
|
);
|
||
|
</script>
|