| <template> |
| <div ref="root" class="relative w-full"> |
| <button |
| type="button" |
| class="flex w-full items-center justify-between gap-2 rounded-full border border-input bg-background px-4 py-2 text-sm |
| text-foreground transition-colors hover:border-primary" |
| @click="toggle" |
| > |
| <span class="truncate">{{ currentLabel }}</span> |
| <svg aria-hidden="true" viewBox="0 0 20 20" class="h-4 w-4" fill="currentColor"> |
| <path d="M5 7l5 6 5-6H5z" /> |
| </svg> |
| </button> |
| <div |
| v-if="open" |
| class="absolute right-0 z-30 w-full space-y-1 rounded-2xl border border-border bg-card p-2 shadow-lg" |
| :class="menuPositionClass" |
| > |
| <button |
| v-for="option in normalizedOptions" |
| :key="option.value" |
| type="button" |
| class="flex w-full items-center justify-between rounded-xl px-3 py-2.5 text-left text-sm transition-colors |
| hover:bg-accent" |
| :class="isSelected(option.value) ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'" |
| @click="select(option.value)" |
| > |
| <span>{{ option.label }}</span> |
| <span v-if="isSelected(option.value)" class="text-xs">OK</span> |
| </button> |
| </div> |
| </div> |
| </template> |
| |
| <script setup lang="ts"> |
| import { computed, onBeforeUnmount, onMounted, ref } from 'vue' |
| |
| type Option = { label: string; value: string } |
| |
| const props = defineProps<{ |
| modelValue: string | string[] |
| options: Array<string | Option> |
| multiple?: boolean |
| placeholder?: string |
| placement?: 'up' | 'down' |
| }>() |
| |
| const emit = defineEmits<{ |
| (e: 'update:modelValue', value: string | string[]): void |
| }>() |
| |
| const open = ref(false) |
| const root = ref<HTMLElement | null>(null) |
| |
| const normalizedOptions = computed<Option[]>(() => |
| props.options.map(option => |
| typeof option === 'string' ? { label: option, value: option } : option |
| ) |
| ) |
| |
| const currentLabel = computed(() => { |
| if (props.multiple) { |
| const values = Array.isArray(props.modelValue) ? props.modelValue : [] |
| if (!values.length) return props.placeholder || '请选择' |
| if (values.length === 1) { |
| const match = normalizedOptions.value.find(option => option.value === values[0]) |
| return match?.label || values[0] |
| } |
| return `已选 ${values.length} 项` |
| } |
| |
| const match = normalizedOptions.value.find(option => option.value === props.modelValue) |
| return match?.label || String(props.modelValue ?? '') |
| }) |
| |
| const menuPositionClass = computed(() => |
| props.placement === 'up' ? 'bottom-full mb-2' : 'mt-2' |
| ) |
| |
| const toggle = () => { |
| open.value = !open.value |
| } |
| |
| const isSelected = (value: string) => { |
| if (props.multiple) { |
| return Array.isArray(props.modelValue) && props.modelValue.includes(value) |
| } |
| return props.modelValue === value |
| } |
| |
| const select = (value: string) => { |
| if (props.multiple) { |
| const current = Array.isArray(props.modelValue) ? props.modelValue : [] |
| const exists = current.includes(value) |
| const next = exists ? current.filter(item => item !== value) : [...current, value] |
| emit('update:modelValue', next) |
| return |
| } |
| |
| emit('update:modelValue', value) |
| open.value = false |
| } |
| |
| const handleClickOutside = (event: MouseEvent) => { |
| if (!root.value) return |
| if (root.value.contains(event.target as Node)) return |
| open.value = false |
| } |
| |
| onMounted(() => { |
| document.addEventListener('click', handleClickOutside) |
| }) |
| |
| onBeforeUnmount(() => { |
| document.removeEventListener('click', handleClickOutside) |
| }) |
| </script> |
| |