jcloud/dashboard/src2/components/Autocomplete.vue
2025-04-12 17:39:38 +08:00

214 lines
6.0 KiB
Vue

<template>
<div class="relative space-y-1.5">
<label class="block text-xs text-gray-600" v-if="label">
{{ label }}
</label>
<Combobox
:modelValue="value"
@update:modelValue="
val => {
emit('update:modelValue', val?.value);
}
"
v-slot="{ open }"
:nullable="nullable"
:multiple="multiple"
>
<Popover class="w-full" ref="rootRef">
<template #target="{ togglePopover }">
<ComboboxButton v-show="false" ref="comboboxButton"></ComboboxButton>
<div
class="border-outline-gray-1 bg-surface-gray-1 text-text-icons-gray-8 hover:border-outline-gray-2 hover:bg-surface-gray-1 form-input flex h-7 w-full items-center justify-between gap-2 rounded p-0 text-sm transition-colors"
>
<ComboboxInput
autocomplete="off"
@focus="
() => {
togglePopover();
if (!open.value) {
$refs.comboboxButton?.$el.click();
}
}
"
@change="handleQueryChange"
:displayValue="getDisplayValue"
:placeholder="!modelValue ? placeholder : null"
class="focus:ring-outline-gray-3 h-full w-full rounded border border-gray-100 bg-gray-100 bg-transparent pl-2 pr-5 text-base text-gray-800 placeholder-gray-500 transition-colors focus:border-gray-500 focus:bg-white focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-gray-400"
/>
</div>
</template>
<template #body="{ isOpen, togglePopover }">
<div v-show="isOpen">
<ComboboxOptions
class="bg-surface-white absolute right-0 z-[999] max-h-[15rem] w-full overflow-y-auto rounded-lg p-0 shadow-2xl"
v-show="filteredOptions.length"
>
<div class="w-full list-none bg-white px-1.5 py-1.5">
<ComboboxOption
v-for="option in filteredOptions"
v-slot="{ active, selected }"
:key="option.value"
:value="option"
:disabled="String(option.value).startsWith('_separator')"
:title="option.label"
class="flex items-center"
@click="
() => {
if (!open.value) {
$refs.comboboxButton?.$el.click();
}
}
"
>
<span
v-if="String(option.value).startsWith('_separator')"
class="!text-text-icons-gray-5 flex w-full items-center gap-2 px-2.5 pb-2 pt-3 text-xs font-medium"
>
{{ option.label }}
</span>
<li
v-else
class="flex w-full cursor-pointer select-none items-center justify-between truncate rounded px-2.5 py-1.5 text-base"
:class="{
'bg-gray-100': active,
'bg-gray-300': selected
}"
>
{{ option.label }}
</li>
</ComboboxOption>
</div>
<div
class="border-outline-gray-2 bg-surface-gray-1 sticky bottom-0 rounded-b-sm border-t"
v-if="actionButton"
>
<component
:is="actionButton.component"
v-if="actionButton?.component"
@change="updateOptions"
></component>
<Button
v-else
:iconLeft="actionButton.icon"
class="text-text-icons-gray-8 w-full rounded-none text-xs"
@click="actionButton.handler"
>
{{ actionButton.label }}
</Button>
</div>
</ComboboxOptions>
</div>
</template>
</Popover>
</Combobox>
<div
class="text-text-icons-gray-4 hover:text-text-icons-gray-5 absolute right-[1px] top-[3px] cursor-pointer p-1"
@click="clearValue"
v-show="modelValue"
>
<FeatherIcon name="x" class="mt-3.5 h-4 w-4 stroke-gray-700" />
</div>
</div>
</template>
<script setup lang="ts">
import {
Combobox,
ComboboxButton,
ComboboxInput,
ComboboxOption,
ComboboxOptions
} from '@headlessui/vue';
import { ComputedRef, PropType, computed, ref, watch } from 'vue';
import { Popover } from 'jingrow-ui';
type Option = {
label: string;
value: string;
};
const emit = defineEmits(['update:modelValue']);
type Action = {
label: String;
handler: () => void;
icon: string;
component?: any;
};
const props = defineProps({
options: {
type: Array as PropType<Option[]>,
default: () => []
},
modelValue: {},
placeholder: {
type: String,
default: ''
},
label: {
type: String,
default: ''
},
// 允许用户输入不在选项中的值
allowInputAsOption: {
type: Boolean,
default: false
},
actionButton: {
type: Object as PropType<Action>,
default: null
}
});
const query = ref('');
const multiple = computed(() => Array.isArray(props.modelValue));
const nullable = computed(() => !multiple.value);
const filteredOptions = ref(props.options);
const getDisplayValue = (option: Option | Option[]) => {
if (Array.isArray(option)) {
return option.map(o => o.label).join(', ');
} else if (option) {
return option.label || option.value || '';
} else {
return '';
}
};
const value = computed(() => {
if (!props.modelValue) {
return null;
}
return (
filteredOptions.value.find(option => option.value === props.modelValue) || {
label: props.modelValue,
value: props.modelValue
}
);
}) as ComputedRef<Option>;
watch(() => query.value || props.options, updateOptions, { immediate: true });
async function updateOptions() {
if (!query.value) {
filteredOptions.value = props.options;
} else {
filteredOptions.value = props.options.filter(option => {
const label = option.label.toLowerCase();
const value = option.label.toLowerCase();
const queryLower = query.value.toLowerCase();
return label.includes(queryLower) || value.includes(query.value);
});
}
}
function handleQueryChange(event: Event) {
query.value = (event.target as HTMLInputElement).value;
if (props.allowInputAsOption && !filteredOptions.value.length) {
emit('update:modelValue', query.value);
}
}
const clearValue = () => emit('update:modelValue', null);
</script>