214 lines
6.0 KiB
Vue
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> |