107 lines
3.3 KiB
Vue
107 lines
3.3 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
|
import { ChevronDown, Check } from 'lucide-vue-next';
|
|
|
|
interface Option {
|
|
value: string;
|
|
label?: string;
|
|
}
|
|
|
|
const props = defineProps<{
|
|
modelValue: string;
|
|
options: (string | Option)[];
|
|
placeholder?: string;
|
|
fullWidth?: boolean;
|
|
triggerClass?: string;
|
|
}>();
|
|
|
|
const emit = defineEmits(['update:modelValue']);
|
|
|
|
const isOpen = ref(false);
|
|
const containerRef = ref<HTMLElement | null>(null);
|
|
|
|
const normalizedOptions = computed(() => {
|
|
return props.options.map(opt => {
|
|
if (typeof opt === 'string') {
|
|
return { value: opt, label: opt };
|
|
}
|
|
return opt;
|
|
});
|
|
});
|
|
|
|
const selectedLabel = computed(() => {
|
|
const found = normalizedOptions.value.find(o => o.value === props.modelValue);
|
|
return found ? (found.label || found.value) : (props.modelValue || props.placeholder || '');
|
|
});
|
|
|
|
const toggle = () => { isOpen.value = !isOpen.value;
|
|
};
|
|
|
|
const select = (value: string) => {
|
|
emit('update:modelValue', value);
|
|
isOpen.value = false;
|
|
};
|
|
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (containerRef.value && !containerRef.value.contains(event.target as Node)) {
|
|
isOpen.value = false;
|
|
}
|
|
};
|
|
|
|
onMounted(() => {
|
|
document.addEventListener('click', handleClickOutside);
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
document.removeEventListener('click', handleClickOutside);
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
ref="containerRef"
|
|
class="relative text-sm"
|
|
:class="{ 'w-full': fullWidth }"
|
|
>
|
|
<!-- Trigger -->
|
|
<button
|
|
type="button"
|
|
@click="toggle"
|
|
class="flex items-center justify-between gap-2 px-3 py-2 transition-colors text-slate-200 focus:outline-none"
|
|
:class="[
|
|
triggerClass ? triggerClass : 'bg-slate-950 border border-slate-800 rounded-lg hover:border-slate-700 focus:ring-1 focus:ring-indigo-500/50',
|
|
{ 'w-full': fullWidth, 'border-indigo-500 ring-1 ring-indigo-500/50': isOpen && !triggerClass }
|
|
]"
|
|
>
|
|
<span class="truncate font-bold">{{ selectedLabel }}</span>
|
|
<ChevronDown class="w-4 h-4 text-slate-500 transition-transform duration-200" :class="{ 'rotate-180': isOpen }" />
|
|
</button>
|
|
|
|
<!-- Dropdown Menu -->
|
|
<transition
|
|
enter-active-class="transition duration-100 ease-out"
|
|
enter-from-class="transform scale-95 opacity-0"
|
|
enter-to-class="transform scale-100 opacity-100"
|
|
leave-active-class="transition duration-75 ease-in"
|
|
leave-from-class="transform scale-100 opacity-100"
|
|
leave-to-class="transform scale-95 opacity-0"
|
|
>
|
|
<div
|
|
v-if="isOpen"
|
|
class="absolute top-full left-0 mt-1 w-full min-w-[120px] max-h-60 overflow-y-auto bg-slate-900 border border-slate-800 rounded-lg shadow-xl z-50 py-1"
|
|
>
|
|
<button
|
|
v-for="opt in normalizedOptions"
|
|
:key="opt.value"
|
|
@click="select(opt.value)"
|
|
class="w-full text-left px-3 py-2 text-sm flex items-center justify-between group hover:bg-slate-800 transition-colors"
|
|
:class="modelValue === opt.value ? 'text-indigo-400 bg-indigo-500/10' : 'text-slate-300'"
|
|
>
|
|
<span class="truncate font-medium">{{ opt.label || opt.value }}</span>
|
|
<Check v-if="modelValue === opt.value" class="w-3.5 h-3.5 text-indigo-500" />
|
|
</button>
|
|
</div>
|
|
</transition>
|
|
</div>
|
|
</template>
|