homemanager-fe/src/components/form/base/Autocomplete.vue

213 lines
6.0 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)"
autocomplete="off"
@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',
props.disabled ? 'bg-gray-100 hover:border-gray-400' : '',
`block w-full rounded-md shadow-sm sm:text-sm transition-colors duration-200`,
'py-2 pl-3 pr-10 text-left cursor-default border-[1px] focus:ring-1',
'sm:min-h-[38px] min-h-[42px]',
];
});
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>