130 lines
4.8 KiB
Vue
130 lines
4.8 KiB
Vue
<script setup lang="ts">
|
|
import { ref, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
|
|
import { ChevronDown, Check } from 'lucide-vue-next';
|
|
|
|
const props = defineProps<{
|
|
modelValue?: string | number;
|
|
options?: { label: string; value: string | number; icon?: string }[];
|
|
placeholder?: string;
|
|
id?: string;
|
|
required?: boolean;
|
|
disabled?: boolean;
|
|
}>();
|
|
|
|
const emit = defineEmits(['update:modelValue']);
|
|
|
|
const isOpen = ref(false);
|
|
const containerRef = ref<HTMLElement | null>(null);
|
|
|
|
const toggle = () => {
|
|
if (!props.disabled) {
|
|
isOpen.value = !isOpen.value;
|
|
}
|
|
};
|
|
|
|
const select = (value: string | number) => {
|
|
emit('update:modelValue', value);
|
|
isOpen.value = false;
|
|
};
|
|
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (containerRef.value && !containerRef.value.contains(event.target as Node)) {
|
|
isOpen.value = false;
|
|
}
|
|
};
|
|
|
|
// Re-init icons when dropdown opens
|
|
watch(isOpen, (val) => {
|
|
if (val) {
|
|
nextTick(() => {
|
|
if ((window as any).lucide) (window as any).lucide.createIcons();
|
|
});
|
|
}
|
|
});
|
|
|
|
onMounted(() => {
|
|
document.addEventListener('click', handleClickOutside);
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
document.removeEventListener('click', handleClickOutside);
|
|
});
|
|
|
|
// Helper to get label for current value
|
|
const currentLabel = () => {
|
|
if (!props.options) return props.modelValue;
|
|
const opt = props.options.find(o => o.value === props.modelValue);
|
|
return opt ? opt.label : props.placeholder || 'Select...';
|
|
};
|
|
|
|
// Helper to get icon for current value (optional, to show icon in trigger)
|
|
const currentIcon = () => {
|
|
if (!props.options) return null;
|
|
const opt = props.options.find(o => o.value === props.modelValue);
|
|
return opt ? opt.icon : null;
|
|
};
|
|
|
|
// Watch modelValue to update trigger icon
|
|
watch(() => props.modelValue, () => {
|
|
nextTick(() => {
|
|
if ((window as any).lucide) (window as any).lucide.createIcons();
|
|
});
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="relative w-full" ref="containerRef">
|
|
<!-- Trigger -->
|
|
<div
|
|
class="flex h-10 w-full items-center justify-between rounded-md border border-[#151515] bg-[#0a0a0a] px-3 py-2 text-sm text-white shadow-sm cursor-pointer transition-all hover:border-[#333]"
|
|
:class="{ 'ring-1 ring-[#00f2ff] border-[#00f2ff]': isOpen, 'opacity-50 cursor-not-allowed': disabled }"
|
|
@click="toggle"
|
|
>
|
|
<span class="flex items-center gap-2" :class="{ 'text-[#666]': !modelValue }">
|
|
<i v-if="currentIcon()" :data-lucide="currentIcon()" class="w-4 h-4"></i>
|
|
{{ currentLabel() }}
|
|
</span>
|
|
<ChevronDown class="h-4 w-4 text-[#666] transition-transform duration-200" :class="{ 'rotate-180': isOpen }" />
|
|
</div>
|
|
|
|
<!-- Dropdown -->
|
|
<transition name="fade-scale">
|
|
<div v-if="isOpen" class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-[#222] bg-[#0a0a0a] py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none custom-scrollbar">
|
|
<div v-if="!options || options.length === 0" class="px-4 py-2 text-sm text-[#666]">No options</div>
|
|
|
|
<div
|
|
v-for="opt in options"
|
|
:key="opt.value"
|
|
class="relative flex cursor-pointer select-none items-center py-2 pl-3 pr-9 text-sm text-[#ccc] hover:bg-[#151515] hover:text-white transition-colors"
|
|
:class="{ 'bg-[#111] text-white': modelValue === opt.value }"
|
|
@click="select(opt.value)"
|
|
>
|
|
<span class="flex items-center gap-2 truncate">
|
|
<i v-if="opt.icon" :data-lucide="opt.icon" class="w-4 h-4"></i>
|
|
{{ opt.label }}
|
|
</span>
|
|
|
|
<span v-if="modelValue === opt.value" class="absolute inset-y-0 right-0 flex items-center pr-4 text-[#00f2ff]">
|
|
<Check class="h-4 w-4" />
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
|
|
<!-- Hidden Native Select for Form Submission/Validation if needed -->
|
|
<select :id="id" :value="modelValue" class="sr-only" :required="required" :disabled="disabled">
|
|
<option v-for="opt in options" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
|
</select>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.custom-scrollbar::-webkit-scrollbar { width: 6px; }
|
|
.custom-scrollbar::-webkit-scrollbar-track { background: #0a0a0a; }
|
|
.custom-scrollbar::-webkit-scrollbar-thumb { background: #222; border-radius: 3px; }
|
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #333; }
|
|
|
|
.fade-scale-enter-active, .fade-scale-leave-active { transition: all 0.15s ease-out; }
|
|
.fade-scale-enter-from, .fade-scale-leave-to { opacity: 0; transform: scale(0.95) translateY(-5px); }
|
|
</style>
|