|
|
<template> |
|
|
<div class="relative"> |
|
|
|
|
|
<div |
|
|
ref="triggerRef" |
|
|
class="relative flex cursor-pointer items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 shadow-sm transition-all duration-200 hover:shadow-md dark:border-gray-600 dark:bg-gray-800" |
|
|
:class="[isOpen && 'border-blue-400 shadow-md']" |
|
|
@click="toggleDropdown" |
|
|
> |
|
|
<i v-if="icon" :class="['fas', icon, 'text-sm', iconColor]"></i> |
|
|
<span |
|
|
class="select-none whitespace-nowrap text-sm font-medium text-gray-700 dark:text-gray-200" |
|
|
> |
|
|
{{ selectedLabel || placeholder }} |
|
|
</span> |
|
|
<i |
|
|
:class="[ |
|
|
'fas fa-chevron-down ml-auto text-xs text-gray-400 transition-transform duration-200 dark:text-gray-500', |
|
|
isOpen && 'rotate-180' |
|
|
]" |
|
|
></i> |
|
|
</div> |
|
|
|
|
|
|
|
|
<Teleport to="body"> |
|
|
<transition |
|
|
enter-active-class="transition duration-200 ease-out" |
|
|
enter-from-class="transform scale-95 opacity-0" |
|
|
enter-to-class="transform scale-100 opacity-100" |
|
|
leave-active-class="transition duration-150 ease-in" |
|
|
leave-from-class="transform scale-100 opacity-100" |
|
|
leave-to-class="transform scale-95 opacity-0" |
|
|
> |
|
|
<div |
|
|
v-if="isOpen" |
|
|
ref="dropdownRef" |
|
|
class="fixed z-[9999] min-w-max overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-600 dark:bg-gray-800" |
|
|
:style="dropdownStyle" |
|
|
> |
|
|
<div class="max-h-60 overflow-y-auto py-1"> |
|
|
<div |
|
|
v-for="option in options" |
|
|
:key="option.value" |
|
|
class="flex cursor-pointer items-center gap-2 whitespace-nowrap px-3 py-2 text-sm transition-colors duration-150" |
|
|
:class="[ |
|
|
option.value === modelValue |
|
|
? 'bg-blue-50 font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' |
|
|
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700' |
|
|
]" |
|
|
@click="selectOption(option)" |
|
|
> |
|
|
<i v-if="option.icon" :class="['fas', option.icon, 'text-xs']"></i> |
|
|
<span>{{ option.label }}</span> |
|
|
<i |
|
|
v-if="option.value === modelValue" |
|
|
class="fas fa-check ml-auto pl-3 text-xs text-blue-600" |
|
|
></i> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</transition> |
|
|
</Teleport> |
|
|
</div> |
|
|
</template> |
|
|
|
|
|
<script setup> |
|
|
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue' |
|
|
|
|
|
const props = defineProps({ |
|
|
modelValue: { |
|
|
type: [String, Number], |
|
|
default: '' |
|
|
}, |
|
|
options: { |
|
|
type: Array, |
|
|
required: true |
|
|
}, |
|
|
placeholder: { |
|
|
type: String, |
|
|
default: '请选择' |
|
|
}, |
|
|
icon: { |
|
|
type: String, |
|
|
default: '' |
|
|
}, |
|
|
iconColor: { |
|
|
type: String, |
|
|
default: 'text-gray-500' |
|
|
} |
|
|
}) |
|
|
|
|
|
const emit = defineEmits(['update:modelValue', 'change']) |
|
|
|
|
|
const isOpen = ref(false) |
|
|
const triggerRef = ref(null) |
|
|
const dropdownRef = ref(null) |
|
|
const dropdownStyle = ref({}) |
|
|
|
|
|
const selectedLabel = computed(() => { |
|
|
const selected = props.options.find((opt) => opt.value === props.modelValue) |
|
|
return selected ? selected.label : '' |
|
|
}) |
|
|
|
|
|
const toggleDropdown = async () => { |
|
|
isOpen.value = !isOpen.value |
|
|
if (isOpen.value) { |
|
|
await nextTick() |
|
|
updateDropdownPosition() |
|
|
} |
|
|
} |
|
|
|
|
|
const closeDropdown = () => { |
|
|
isOpen.value = false |
|
|
} |
|
|
|
|
|
const selectOption = (option) => { |
|
|
emit('update:modelValue', option.value) |
|
|
emit('change', option.value) |
|
|
closeDropdown() |
|
|
} |
|
|
|
|
|
const updateDropdownPosition = () => { |
|
|
if (!triggerRef.value || !isOpen.value) return |
|
|
|
|
|
const trigger = triggerRef.value.getBoundingClientRect() |
|
|
const dropdownHeight = 250 |
|
|
const spaceBelow = window.innerHeight - trigger.bottom |
|
|
const spaceAbove = trigger.top |
|
|
|
|
|
let top, left |
|
|
|
|
|
|
|
|
if (spaceBelow >= dropdownHeight || spaceBelow >= spaceAbove) { |
|
|
|
|
|
top = trigger.bottom + 8 |
|
|
} else { |
|
|
|
|
|
top = trigger.top - dropdownHeight - 8 |
|
|
} |
|
|
|
|
|
|
|
|
left = trigger.left |
|
|
|
|
|
|
|
|
const dropdownWidth = 200 |
|
|
if (left + dropdownWidth > window.innerWidth) { |
|
|
left = window.innerWidth - dropdownWidth - 10 |
|
|
} |
|
|
|
|
|
|
|
|
if (left < 10) { |
|
|
left = 10 |
|
|
} |
|
|
|
|
|
dropdownStyle.value = { |
|
|
top: `${top}px`, |
|
|
left: `${left}px`, |
|
|
minWidth: `${trigger.width}px` |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const handleScroll = () => { |
|
|
if (isOpen.value) { |
|
|
updateDropdownPosition() |
|
|
} |
|
|
} |
|
|
|
|
|
const handleResize = () => { |
|
|
if (isOpen.value) { |
|
|
closeDropdown() |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const handleClickOutside = (event) => { |
|
|
if (!triggerRef.value || !isOpen.value) return |
|
|
|
|
|
|
|
|
if (!triggerRef.value.contains(event.target)) { |
|
|
if (dropdownRef.value && !dropdownRef.value.contains(event.target)) { |
|
|
closeDropdown() |
|
|
} else if (!dropdownRef.value) { |
|
|
closeDropdown() |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
onMounted(() => { |
|
|
window.addEventListener('scroll', handleScroll, true) |
|
|
window.addEventListener('resize', handleResize) |
|
|
document.addEventListener('click', handleClickOutside) |
|
|
}) |
|
|
|
|
|
onBeforeUnmount(() => { |
|
|
window.removeEventListener('scroll', handleScroll, true) |
|
|
window.removeEventListener('resize', handleResize) |
|
|
document.removeEventListener('click', handleClickOutside) |
|
|
}) |
|
|
</script> |
|
|
|
|
|
<style scoped> |
|
|
|
|
|
.max-h-60::-webkit-scrollbar { |
|
|
width: 6px; |
|
|
} |
|
|
|
|
|
.max-h-60::-webkit-scrollbar-track { |
|
|
background: #f3f4f6; |
|
|
border-radius: 3px; |
|
|
} |
|
|
|
|
|
.max-h-60::-webkit-scrollbar-thumb { |
|
|
background: #d1d5db; |
|
|
border-radius: 3px; |
|
|
} |
|
|
|
|
|
.max-h-60::-webkit-scrollbar-thumb:hover { |
|
|
background: #9ca3af; |
|
|
} |
|
|
</style> |
|
|
|