当前位置: 首页 > news >正文

vue3+ts 封装跟随弹框组件,支持多种模式【多选,分组,tab等】

组件:SmartSelectorPlus.vue

<!-- 智能跟随弹框组件 -->
<template><div class="smart-popover-container" ref="containerRef"><!-- 触发器插槽 --><div ref="triggerRef" class="smart-popover-trigger" @click.stop="handleTriggerClick" @mouseenter="handleMouseEnter"@mouseleave="handleMouseLeave"><slot name="trigger"><el-input ref="inputRef" v-model="displayValue" :placeholder="placeholder" :style="{ width: inputWidth }":type="multiline ? 'textarea' : 'text'" :autosize="autosize" :size="size" :readonly="readonly":disabled="disabled" :clearable="clearable" @focus="handleInputFocus" @blur="handleInputBlur"@clear="handleClear"><template #suffix><el-icon :class="{ 'rotate-180': visible }"><arrow-down /></el-icon></template></el-input></slot></div><!-- 弹框内容 --><teleport to="body"><transition name="smart-popover-fade"><div v-if="visible" ref="popoverRef" class="smart-popover-content":class="[popoverClass, `placement-${placement}`]" :style="popoverStyle" @click.stop><!-- 箭头指示器 --><div v-if="showArrow" class="smart-popover-arrow" :style="arrowStyle"></div><div class="smart-popover-inner" :style="{ maxHeight: maxHeight }"><!-- 自定义头部 --><div v-if="$slots.header" class="smart-popover-header"><slot name="header"></slot></div><!-- Tab栏 --><el-tabs v-if="hasTabs" v-model="activeTab" class="smart-popover-tabs" @tab-click="handleTabChange"><el-tab-pane v-for="tab in tabs" :key="tab.name" :label="tab.label" :name="tab.name" /></el-tabs><!-- 搜索框 --><div v-if="searchable" class="smart-popover-search"><el-input v-model="searchText" placeholder="搜索选项..." size="small" clearable @input="handleSearch"><template #prefix><el-icon><search /></el-icon></template></el-input></div><!-- 滚动内容区域 --><el-scrollbar class="smart-popover-scroll"><!-- 自定义内容插槽 --><slot v-if="$slots.default" :close="closePopover"></slot><!-- 默认选项内容 --><template v-else><template v-for="(group, index) in filteredGroups" :key="index"><!-- 分组标题 --><div v-if="group.title" class="group-title">{{ group.title }}</div><!-- 选项网格 --><div class="options-grid" :class="gridClass"><div v-for="(item, itemIndex) in group.options" :key="`${index}-${itemIndex}`" class="option-item":class="{'is-selected': isSelected(item),'is-disabled': isDisabled(item)}" @click="handleSelect(item)"><!-- 自定义选项内容 --><slot name="option" :item="item" :selected="isSelected(item)">{{ getOptionLabel(item) }}</slot></div></div><!-- 分组分割线 --><el-divider v-if="index < filteredGroups.length - 1" /></template><!-- 无数据提示 --><div v-if="filteredGroups.length === 0" class="empty-content"><slot name="empty"><el-empty description="暂无数据" :image-size="80" /></slot></div></template></el-scrollbar><!-- 自定义底部 --><div v-if="$slots.footer" class="smart-popover-footer"><slot name="footer" :close="closePopover"></slot></div></div></div></transition></teleport></div>
</template><script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { ArrowDown, Search } from '@element-plus/icons-vue'// 类型定义
interface Option {label?: stringvalue?: anydisabled?: boolean[key: string]: any
}interface Group {title?: stringoptions: Option[]
}interface Tab {name: stringlabel: stringoptions?: Option[]groups?: Group[]
}// modelValue: 绑定值
// options/groups/tabs: 选项数据
// multiple: 多选模式
// searchable: 是否可搜索
// trigger: 触发方式(click/hover/focus/manual)
// placement: 弹出位置
// width/maxHeight: 尺寸设置
// gridColumns: 网格列数
const props = withDefaults(defineProps<{modelValue?: string | number | Array<any>// options?: Option[]options?: any// groups?: Group[]groups?: any// tabs?: Tab[]tabs?: anyplaceholder?: stringwidth?: string | numberinputWidth?: stringmaxHeight?: stringseparator?: stringmultiline?: booleanautosize?: object | booleanplacement?: 'top' | 'bottom' | 'left' | 'right' | 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end'readonly?: booleandisabled?: booleanclearable?: booleanpopoverClass?: stringsize?: 'large' | 'default' | 'small'singleSelect?: booleanmultiple?: booleansearchable?: booleanshowArrow?: booleantrigger?: 'click' | 'hover' | 'focus' | 'manual'offset?: numberzIndex?: numbergridColumns?: number | 'auto'
}>(), {placeholder: '请选择',width: 300,inputWidth: '200px',maxHeight: '300px',separator: ',',placement: 'bottom-start',size: 'default',trigger: 'click',offset: 8,zIndex: 2000,gridColumns: 'auto'
})const emit = defineEmits(['update:modelValue', 'select', 'clear', 'open', 'close', 'tab-change', 'search'])// 响应式数据
const containerRef = ref<HTMLElement>()
const triggerRef = ref<HTMLElement>()
const popoverRef = ref<HTMLElement>()
const inputRef = ref()
const visible = ref(false)
const activeTab = ref('')
const searchText = ref('')
const popoverStyle = ref({})
const arrowStyle = ref({})// 计算属性
const hasTabs = computed(() => props.tabs && props.tabs.length > 0)const currentGroups = computed(() => {if (hasTabs.value && props.tabs) {const tab = props.tabs.find((t: any) => t.name === activeTab.value)if (tab?.groups) return tab.groupsif (tab?.options) return [{ options: tab.options }]return []}return props.groups && props.groups.length > 0 ? props.groups : [{ options: props.options || [] }]
})const filteredGroups: any = computed(() => {if (!searchText.value.trim()) return currentGroups.valuereturn currentGroups.value.map((group: any) => ({...group,options: group.options.filter((item: any) => {const label = getOptionLabel(item).toLowerCase()return label.includes(searchText.value.toLowerCase())})})).filter((group: any) => group.options.length > 0)
})const displayValue = computed({get: () => {if (props.multiple || Array.isArray(props.modelValue)) {const values = Array.isArray(props.modelValue) ? props.modelValue : []return values.join(props.separator)}return props.modelValue?.toString() || ''},set: (val) => {if (props.multiple) {const values = val ? val.split(props.separator) : []emit('update:modelValue', values)} else {emit('update:modelValue', val)}}
})const gridClass = computed(() => {if (typeof props.gridColumns === 'number') {return `grid-cols-${props.gridColumns}`}return 'grid-auto'
})// 工具函数
const getOptionLabel = (item: Option | string): string => {if (typeof item === 'string') return itemreturn item?.label || item?.value?.toString() || ''
}const getOptionValue = (item: Option | string): any => {if (typeof item === 'string') return itemreturn item?.value !== undefined ? item.value : item?.label
}const isSelected = (item: Option | string): boolean => {const value = getOptionValue(item)if (props.multiple || Array.isArray(props.modelValue)) {const values = Array.isArray(props.modelValue) ? props.modelValue : []return values.includes(value)}return props.modelValue === value
}const isDisabled = (item: Option | string): boolean => {if (typeof item === 'string') return falsereturn Boolean(item?.disabled)
}// 弹框位置计算
const calculatePosition = () => {if (!triggerRef.value || !popoverRef.value) returnconst triggerRect = triggerRef.value.getBoundingClientRect()const popoverRect = popoverRef.value.getBoundingClientRect()const viewport = {width: window.innerWidth,height: window.innerHeight}let top = 0let left = 0let arrowTop = ''let arrowLeft = ''switch (props.placement) {case 'bottom':case 'bottom-start':top = triggerRect.bottom + props.offsetleft = props.placement === 'bottom'? triggerRect.left + (triggerRect.width - popoverRect.width) / 2: triggerRect.leftarrowTop = `-${props.offset}px`arrowLeft = props.placement === 'bottom'? '50%': `${Math.min(20, triggerRect.width / 2)}px`breakcase 'bottom-end':top = triggerRect.bottom + props.offsetleft = triggerRect.right - popoverRect.widtharrowTop = `-${props.offset}px`arrowLeft = `${popoverRect.width - Math.min(20, triggerRect.width / 2)}px`breakcase 'top':case 'top-start':top = triggerRect.top - popoverRect.height - props.offsetleft = props.placement === 'top'? triggerRect.left + (triggerRect.width - popoverRect.width) / 2: triggerRect.leftarrowTop = `${popoverRect.height}px`arrowLeft = props.placement === 'top'? '50%': `${Math.min(20, triggerRect.width / 2)}px`break// 可以继续添加其他位置...}// 边界检查和调整if (left < 10) left = 10if (left + popoverRect.width > viewport.width - 10) {left = viewport.width - popoverRect.width - 10}if (top < 10) top = triggerRect.bottom + props.offsetif (top + popoverRect.height > viewport.height - 10) {top = triggerRect.top - popoverRect.height - props.offset}popoverStyle.value = {position: 'fixed',top: `${top}px`,left: `${left}px`,width: typeof props.width === 'number' ? `${props.width}px` : props.width,zIndex: props.zIndex ?? 3000}if (props.showArrow) {arrowStyle.value = {top: arrowTop,left: arrowLeft,transform: arrowLeft === '50%' ? 'translateX(-50%)' : ''}}
}// 事件处理
const openPopover = async () => {if (props.disabled || visible.value) returnvisible.value = trueemit('open')await nextTick()calculatePosition()
}const closePopover = () => {if (!visible.value) returnvisible.value = falsesearchText.value = ''emit('close')
}const handleTriggerClick = () => {if (props.trigger === 'click') {visible.value ? closePopover() : openPopover()}
}const handleMouseEnter = () => {if (props.trigger === 'hover') {openPopover()}
}const handleMouseLeave = () => {if (props.trigger === 'hover') {setTimeout(() => {if (!popoverRef.value?.matches(':hover')) {closePopover()}}, 100)}
}const handleInputFocus = () => {if (props.trigger === 'focus') {openPopover()}
}const handleInputBlur = () => {if (props.trigger === 'focus') {setTimeout(() => {if (!popoverRef.value?.matches(':hover')) {closePopover()}}, 200)}
}const handleSelect = (item: Option | string) => {if (isDisabled(item)) returnconst value = getOptionValue(item)if (props.singleSelect || (!props.multiple && !Array.isArray(props.modelValue))) {// 单选模式emit('update:modelValue', value)emit('select', typeof item === 'string' ? { value: item, label: item } : item)closePopover()} else {// 多选模式const currentValues = Array.isArray(props.modelValue) ? [...props.modelValue] : []const index = currentValues.indexOf(value)if (index > -1) {currentValues.splice(index, 1)} else {currentValues.push(value)}emit('update:modelValue', currentValues)emit('select', typeof item === 'string' ? { value: item, label: item } : item)}
}const handleTabChange = (tab: any) => {activeTab.value = tab.props.nameemit('tab-change', tab.props.name)
}const handleSearch = (keyword: string) => {emit('search', keyword)
}const handleClear = () => {emit('update:modelValue', props.multiple ? [] : '')emit('clear')
}// 点击外部关闭
const handleClickOutside = (event: MouseEvent) => {if (!visible.value) returnconst target = event.target as Nodeif (containerRef.value?.contains(target) ||popoverRef.value?.contains(target)) {return}closePopover()
}// 键盘事件
const handleKeydown = (event: KeyboardEvent) => {if (event.key === 'Escape') {closePopover()}
}// 窗口调整大小时重新计算位置
const handleResize = () => {if (visible.value) {calculatePosition()}
}// 生命周期
onMounted(() => {// 初始化第一个Tabif (hasTabs.value && props.tabs) {activeTab.value = props.tabs[0].name}document.addEventListener('click', handleClickOutside, true)document.addEventListener('keydown', handleKeydown)window.addEventListener('resize', handleResize)
})onUnmounted(() => {document.removeEventListener('click', handleClickOutside, true)document.removeEventListener('keydown', handleKeydown)window.removeEventListener('resize', handleResize)
})// 监听器
watch(visible, (newVisible) => {if (newVisible) {nextTick(calculatePosition)}
})// 暴露方法
defineExpose({open: openPopover,close: closePopover,toggle: () => visible.value ? closePopover() : openPopover()
})
</script><style scoped lang="scss">
.smart-popover-container {position: relative;display: inline-block;
}.smart-popover-trigger {.rotate-180 {transform: rotate(180deg);transition: transform 0.3s;}
}.smart-popover-content {background: white;border-radius: 8px;box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);border: 1px solid #e4e7ed;position: fixed;z-index: 3000;&.placement-bottom,&.placement-bottom-start,&.placement-bottom-end {.smart-popover-arrow {top: -8px;border-left: 8px solid transparent;border-right: 8px solid transparent;border-bottom: 8px solid white;&::before {content: '';position: absolute;top: -1px;left: -8px;border-left: 8px solid transparent;border-right: 8px solid transparent;border-bottom: 8px solid #e4e7ed;}}}
}.smart-popover-arrow {position: absolute;width: 0;height: 0;
}.smart-popover-inner {padding: 12px;overflow: hidden;z-index: 9999;
}.smart-popover-header {margin-bottom: 12px;padding-bottom: 8px;border-bottom: 1px solid #f0f0f0;
}.smart-popover-tabs {:deep(.el-tabs__header) {margin: 0 0 12px 0;}
}.smart-popover-search {margin-bottom: 12px;
}.smart-popover-scroll {max-height: inherit;
}.smart-popover-footer {margin-top: 12px;padding-top: 8px;border-top: 1px solid #f0f0f0;
}.group-title {padding: 8px 0;font-weight: 600;color: var(--el-color-primary);font-size: 14px;
}.options-grid {display: grid;gap: 8px;padding: 4px 0;&.grid-auto {grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));}&.grid-cols-1 {grid-template-columns: repeat(1, 1fr);}&.grid-cols-2 {grid-template-columns: repeat(2, 1fr);}&.grid-cols-3 {grid-template-columns: repeat(3, 1fr);}&.grid-cols-4 {grid-template-columns: repeat(4, 1fr);}&.grid-cols-5 {grid-template-columns: repeat(5, 1fr);}&.grid-cols-6 {grid-template-columns: repeat(6, 1fr);}
}.option-item {padding: 8px 12px;background: #f8f9fa;border-radius: 6px;cursor: pointer;transition: all 0.2s ease;text-align: center;font-size: 13px;border: 1px solid transparent;user-select: none;&:hover:not(.is-disabled) {background: var(--el-color-primary-light-8);border-color: var(--el-color-primary-light-5);transform: translateY(-1px);}&.is-selected {background: var(--el-color-primary);color: white;font-weight: 500;}&.is-disabled {background: #f5f7fa;color: #c0c4cc;cursor: not-allowed;opacity: 0.6;}
}.empty-content {padding: 20px;text-align: center;
}:deep(.el-divider--horizontal) {margin: 12px 0;
}// 过渡动画
.smart-popover-fade-enter-active,
.smart-popover-fade-leave-active {transition: all 0.2s ease;
}.smart-popover-fade-enter-from,
.smart-popover-fade-leave-to {opacity: 0;transform: scale(0.95);
}
</style>

使用示例:

<template><div class="examples-container"><h1>SmartPopoverPlus 使用示例</h1><!-- 1. 基础用法 --><section class="example-section"><h2>1. 基础用法</h2><SmartPopoverPlusv-model="form.basic":options="basicOptions"placeholder="选择基础选项"@select="handleSelect"/><p>已选择: {{ form.basic }}</p></section><!-- 2. 多选模式 --><section class="example-section"><h2>2. 多选模式</h2><SmartPopoverPlusv-model="form.multiple":options="basicOptions"placeholder="多选模式"multipleseparator=" | "/><p>已选择: {{ form.multiple }}</p></section><!-- 3. 分组选项 --><section class="example-section"><h2>3. 分组选项</h2><SmartPopoverPlusv-model="form.grouped":groups="groupedOptions"placeholder="分组选择"width="400px"searchable/><p>已选择: {{ form.grouped }}</p></section><!-- 4. Tab栏模式 --><section class="example-section"><h2>4. Tab栏模式</h2><SmartPopoverPlusv-model="form.tabbed":tabs="tabbedOptions"placeholder="Tab栏选择"width="500px"multiple@tab-change="handleTabChange"/><p>已选择: {{ form.tabbed }}</p></section><!-- 5. 自定义触发器 --><section class="example-section"><h2>5. 自定义触发器</h2><SmartPopoverPlusv-model="form.customTrigger":options="basicOptions"><template #trigger><el-button type="primary">自定义按钮触发器<el-icon><ArrowDown /></el-icon></el-button></template></SmartPopoverPlus><p>已选择: {{ form.customTrigger }}</p></section><!-- 6. 自定义选项内容 --><section class="example-section"><h2>6. 自定义选项内容</h2><SmartPopoverPlusv-model="form.customOption":options="userOptions"placeholder="选择用户"width="300px"><template #option="{ item, selected }"><div class="custom-option" :class="{ 'is-selected': selected }"><el-avatar :size="24" :src="item.avatar" /><div class="user-info"><div class="name">{{ item.label }}</div><div class="role">{{ item.role }}</div></div></div></template></SmartPopoverPlus><p>已选择: {{ form.customOption }}</p></section><!-- 7. Hover触发 --><section class="example-section"><h2>7. Hover触发</h2><SmartPopoverPlusv-model="form.hover":options="basicOptions"trigger="hover"placeholder="鼠标悬停触发"/><p>已选择: {{ form.hover }}</p></section><!-- 8. 完全自定义内容 --><section class="example-section"><h2>8. 完全自定义内容</h2><SmartPopoverPlusv-model="form.custom"placeholder="自定义内容"width="350px"><template #header><div style="font-weight: bold; color: #409eff;">自定义头部</div></template><template #default="{ close }"><div class="custom-content"><h4>这是完全自定义的内容</h4><p>你可以放置任何内容在这里</p><el-button-group><el-button size="small" @click="handleCustomAction('action1', close)">操作1</el-button><el-button size="small" @click="handleCustomAction('action2', close)">操作2</el-button></el-button-group></div></template><template #footer="{ close }"><div style="text-align: right;"><el-button size="small" @click="close">关闭</el-button></div></template></SmartPopoverPlus><p>已选择: {{ form.custom }}</p></section><!-- 9. 不同网格布局 --><section class="example-section"><h2>9. 网格布局</h2><el-row :gutter="20"><el-col :span="8"><SmartPopoverPlusv-model="form.grid2":options="basicOptions"placeholder="2列布局":grid-columns="2"width="200px"/></el-col><el-col :span="8"><SmartPopoverPlusv-model="form.grid3":options="basicOptions"placeholder="3列布局":grid-columns="3"width="250px"/></el-col><el-col :span="8"><SmartPopoverPlusv-model="form.gridAuto":options="basicOptions"placeholder="自动布局"grid-columns="auto"width="300px"/></el-col></el-row></section><!-- 10. 程序控制 --><section class="example-section"><h2>10. 程序控制</h2><el-space><SmartPopoverPlusref="programmaticRef"v-model="form.programmatic":options="basicOptions"trigger="manual"placeholder="程序控制"/><el-button @click="openProgrammatic">打开</el-button><el-button @click="closeProgrammatic">关闭</el-button><el-button @click="toggleProgrammatic">切换</el-button></el-space></section></div>
</template><script setup lang="ts">
import { ref, reactive } from 'vue'
import { ArrowDown } from '@element-plus/icons-vue'
import SmartPopoverPlus from './SmartSelectorPlus.vue'const form = reactive({basic: '',multiple: [],grouped: '',tabbed: [],customTrigger: '',customOption: '',hover: '',custom: '',grid2: '',grid3: '',gridAuto: '',programmatic: ''
})const programmaticRef = ref()// 基础选项
const basicOptions = ['选项一', '选项二', '选项三', '选项四', '选项五', '选项六'
]// 分组选项
const groupedOptions = [{title: '常用',options: ['苹果', '香蕉', '橙子']},{title: '其他',options: ['葡萄', '西瓜', '梨子']}
]// Tab栏选项
const tabbedOptions = [{name: '水果',label: '水果',options: ['苹果', '香蕉', '橙子']},{name: '蔬菜',label: '蔬菜',options: ['西红柿', '黄瓜', '胡萝卜']}
]// 用户选项(自定义内容示例)
const userOptions = [{ label: '张三', value: 'zhangsan', role: '管理员', avatar: 'https://i.pravatar.cc/40?img=1' },{ label: '李四', value: 'lisi', role: '编辑', avatar: 'https://i.pravatar.cc/40?img=2' },{ label: '王五', value: 'wangwu', role: '访客', avatar: 'https://i.pravatar.cc/40?img=3' }
]// 事件处理
function handleSelect(item:any) {console.log('选择了:', item)
}function handleTabChange(name:string) {console.log('切换到 Tab:', name)
}function handleCustomAction(action:string, close:Function) {console.log('执行自定义操作:', action)close()
}// 程序控制
function openProgrammatic() {programmaticRef.value?.open()
}
function closeProgrammatic() {programmaticRef.value?.close()
}
function toggleProgrammatic() {programmaticRef.value?.toggle()
}
</script><style scoped>
.examples-container {padding: 20px;
}
.example-section {margin-bottom: 40px;
}
.custom-option {display: flex;align-items: center;gap: 8px;
}
.custom-option.is-selected {background: #409eff20;
}
.user-info .name {font-weight: 600;
}
.user-info .role {font-size: 12px;color: #909399;
}
</style>
http://www.dtcms.com/a/419025.html

相关文章:

  • 网站开发注意事项wordpress 专业版主题
  • 2025甄选范文“论事件驱动的架构”,软考高级,系统架构设计师论文
  • 高通平台WiFi学习--深入解析 WCN39xx/PMIC GPIO/LDO 状态读取与调试
  • 评估止损算法在历史极端行情中表现的一些复盘
  • 英飞凌Coolgan提升Poe性能
  • 网站解析多久网站开发是做什么?
  • 有哪些好的做兼职网站有哪些做网站域名需哪些
  • FFmpeg过滤器实战:水印处理
  • 网站推广好难免费建网站代理
  • 东莞网站建设主要学什么北京有哪些著名网站
  • 英文版科技网站网站推广套餐
  • 网站建设与开发课程内容wordpress 启动wordpress mu
  • 10.4 线性规划
  • 【Svelte】比较 onMount 和 browser,以及客户端获取数据时,应该使用谁?
  • 欢迎学习《现代控制理论》——自动化专业的核心课程
  • 强化学习的数学原理-04章 策略评估与策略优化
  • 广州网站建设 .超凡科技新网网站登录不上
  • week 3
  • 建设网站 课程设计怎样用手机做网站
  • 图文讲解k8s中Service、Selector、EndpointSlice的运行原理
  • 菊风智能双录+质检+可视化回溯,组合拳助力金融合规数字化升级
  • k8s中的kubelet
  • 精读C++20设计模式——结构型设计模式:适配器模式
  • 如何用visual做网站十大国际贸易公司排名
  • 网站建设 仿站什么是电商?电商怎么做
  • 2025数据治理平台品牌TOP16榜单:技术突破与选型指南
  • 网站快速收录平台dede做的网站打不开
  • LeetCode 230. 二叉搜索树中第 K 小的元素
  • 优秀的平面设计网站国内做的比较好的旅游网站
  • 设计模式(C++)详解——中介者模式(2)