<script setup lang="ts" generic="T extends { id: string; label: string; icon: string }">
import { ref } from 'vue';
/**
* SwitcherDropdown — 通用图标+下拉切换组件
*
* 用途:主题切换、布局切换,或任何需要"小图标 → 下拉选单"形式的场景。
*
* Props:
* options — 选项列表,每项需要 { id, label, icon }
* modelValue — 当前选中项的 id(支持 v-model)
* title — 按钮 tooltip 文字(可选)
*
* Slots:
* #icon — 自定义触发按钮内的图标(不覆盖则使用默认 SVG)
* #option — 自定义下拉选项内容(slot props: { option, active })
*
* Emits:
* update:modelValue — 切换时触发,携带新 id
*/
const props = withDefaults(
defineProps<{
options: T[];
modelValue: string;
title?: string;
}>(),
{ title: '' },
);
const emit = defineEmits<{
'update:modelValue': [id: string];
}>();
const open = ref(false);
function select(id: string) {
emit('update:modelValue', id);
open.value = false;
}
function toggle() {
open.value = !open.value;
}
function close() {
open.value = false;
}
// 暴露给父组件
defineExpose({ close });
</script>
<template>
<!-- @click.stop 防止冒泡到外层关闭逻辑 -->
<div class="sw-drop" @click.stop>
<!-- 触发按钮 -->
<button class="tn-btn sw-trigger" :title="title" @click="toggle">
<slot name="icon">
<!-- 默认:调色板图标 -->
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="4" />
</svg>
</slot>
</button>
<!-- 下拉菜单 -->
<Transition name="sw-fade">
<div v-if="open" class="sw-menu">
<div
v-for="opt in options"
:key="opt.id"
class="sw-item"
:class="{ active: modelValue === opt.id }"
@click="select(opt.id)"
>
<!-- 支持通过 #option 插槽自定义每行内容 -->
<slot name="option" :option="opt" :active="modelValue === opt.id">
<span class="sw-i-icon">{{ opt.icon }}</span>
<span class="sw-i-label">{{ opt.label }}</span>
<svg
v-if="modelValue === opt.id"
class="sw-i-check"
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<polyline points="20 6 9 17 4 12" />
</svg>
</slot>
</div>
</div>
</Transition>
</div>
</template>
<style lang="scss" scoped>
.sw-drop {
position: relative;
}
/* 触发按钮——复用 .tn-btn 样式,附加颜色覆盖 */
.sw-trigger {
color: var(--tn-t, #74b898) !important;
&:hover {
background: rgba(255, 255, 255, 0.12) !important;
color: var(--tn-ac, #4ec685) !important;
}
}
/* 下拉菜单面板 */
.sw-menu {
position: absolute;
right: 0;
top: calc(100% + 8px);
background: var(--card, #ffffff);
border: 1px solid var(--bd, #e0e6da);
border-radius: 10px;
box-shadow: var(--shadow-lg, 0 8px 24px rgba(25, 36, 24, 0.12));
min-width: 148px;
overflow: hidden;
z-index: 300;
padding: 4px;
}
/* 选项行 */
.sw-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 7px;
cursor: pointer;
color: var(--t2, #4a604a);
font-size: 13px;
font-weight: 500;
transition:
background 0.12s,
color 0.12s;
&:hover {
background: var(--ac-l, #e8f5ee);
color: var(--ac, #1d7040);
}
&.active {
color: var(--ac, #1d7040);
font-weight: 600;
}
}
.sw-i-icon {
font-size: 14px;
flex-shrink: 0;
}
.sw-i-label {
flex: 1;
}
.sw-i-check {
color: var(--ac, #1d7040);
flex-shrink: 0;
}
/* 下拉动画 */
.sw-fade-enter-active,
.sw-fade-leave-active {
transition:
opacity 0.12s,
transform 0.12s;
}
.sw-fade-enter-from,
.sw-fade-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>
|