<template>
<view class="page-container">
<text class="title">Dropdown 下拉菜单示例</text>
<!-- ==================== 示例1:基础用法 + 自定义触发器 ==================== -->
<view class="demo-section">
<text class="label">选择分类:</text>
<zy-popup-dropdown-menu
v-model="selectedCategory"
:data="categoryList"
label-key="name"
value-key="value"
@change="handleChange"
@open="onOpen"
@close="onClose"
>
<template #reference="{ open }">
<!-- ✅ 必须添加 data-dropdown-trigger -->
<view data-dropdown-trigger class="custom-trigger" @tap="open">
{{ selectedCategoryLabel }} <text class="arrow">▼</text>
</view>
</template>
</zy-popup-dropdown-menu>
</view>
<!-- ==================== 示例2:城市选择(长文本测试) ==================== -->
<view class="demo-section">
<text class="label">选择城市:</text>
<zy-popup-dropdown-menu
v-model="selectedCity"
:data="cityList"
label-key="name"
value-key="code"
@change="val => console.log('城市变更:', val)"
>
<template #reference="{ open }">
<view data-dropdown-trigger class="custom-trigger wide" @tap="open">
{{ selectedCityLabel || '请选择城市...' }}
</view>
</template>
</zy-popup-dropdown-menu>
</view>
<!-- ==================== 示例3:默认触发器(最简写法)==================== -->
<view class="demo-section">
<text class="label">直接使用默认按钮:</text>
<zy-popup-dropdown-menu
v-model="selectedTheme"
:data="themeList"
@change="val => uni.showToast({ title: `主题: ${val.label}`, icon: 'none' })"
/>
</view>
<!-- ==================== 示例4:通过 ref 控制打开关闭 ==================== -->
<view class="demo-section">
<text class="label">通过 Ref 手动控制:</text>
<zy-popup-dropdown-menu
ref="menuRef"
v-model="selectedAction"
:data="actionList"
@change="val => console.log('操作:', val)"
>
<template #reference>
<view class="custom-trigger" @tap="handleManualOpen"> {{ selectedActionLabel }} ▼ </view>
</template>
</zy-popup-dropdown-menu>
<view class="button-group">
<button @tap="menuRef.open()">打开</button>
<button @tap="menuRef.close()">关闭</button>
<button @tap="menuRef.toggle()">切换</button>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
// ----------------------
// 数据源
// ----------------------
// 分类列表
const categoryList = ref([
{ name: '美食', value: 'food' },
{ name: '旅游', value: 'travel' },
{ name: '科技数码产品推荐', value: 'tech' },
{ name: '健身运动与户外', value: 'fitness' },
{ name: '摄影艺术创作', value: 'photo' }
])
// 城市列表(含长文本)
const cityList = ref([
{ name: '北京市', code: 'beijing' },
{ name: '上海市', code: 'shanghai' },
{ name: '广州市 - 南方重要经济中心', code: 'guangzhou' },
{ name: '深圳市 - 创新科技之都', code: 'shenzhen' },
{ name: '成都市 - 西部休闲文化名城,生活节奏慢', code: 'chengdu' }
])
// 主题列表
const themeList = ref([
{ name: '浅色模式', value: 'light' },
{ name: '深色模式', value: 'dark' },
{ name: '自动切换', value: 'auto' }
])
// 操作列表
const actionList = ref([
{ name: '编辑', value: 'edit' },
{ name: '删除', value: 'delete' },
{ name: '分享', value: 'share' },
{ name: '导出数据', value: 'export' }
])
// ----------------------
// 当前选中值
// ----------------------
const selectedCategory = ref('travel')
const selectedCity = ref('')
const selectedTheme = ref('light')
const selectedAction = ref('edit')
// ----------------------
// 计算显示文本
// ----------------------
const selectedCategoryLabel = computed(() => {
const item = categoryList.value.find(item => item.value === selectedCategory.value)
return item ? item.name : '请选择...'
})
const selectedCityLabel = computed(() => {
const item = cityList.value.find(item => item.code === selectedCity.value)
return item ? item.name : '请选择城市...'
})
const selectedActionLabel = computed(() => {
const item = actionList.value.find(item => item.value === selectedAction.value)
return item ? item.name : '请选择操作'
})
// ----------------------
// 事件处理
// ----------------------
const handleChange = ({ value, label }) => {
uni.showToast({
title: `选择了: ${label}`,
icon: 'none',
duration: 1500
})
}
const onOpen = currentValue => {
console.log('[Dropdown] 打开事件,当前值:', currentValue)
}
const onClose = () => {
console.log('[Dropdown] 已关闭')
}
// ----------------------
// Ref 手动控制
// ----------------------
const menuRef = ref(null)
const handleManualOpen = () => {
menuRef.value?.open()
}
</script>
<style scoped>
.page-container {
padding: 20px;
padding-top: 200px;
background-color: #f8f9fa;
min-height: 100vh;
}
.title {
font-size: 18px;
font-weight: bold;
color: #1a1a1a;
margin-bottom: 20px;
display: block;
}
.demo-section {
margin-bottom: 30px;
}
.label {
display: block;
font-size: 15px;
color: #555;
margin-bottom: 8px;
}
/* 自定义触发器样式 */
.custom-trigger {
display: inline-flex;
align-items: center;
justify-content: space-between;
width: 200px;
padding: 10px 15px;
background-color: #ffffff;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 15px;
color: #333;
box-sizing: border-box;
}
.custom-trigger.wide {
width: 250px;
}
.arrow {
font-size: 12px;
color: #999;
}
/* 按钮组 */
.button-group {
display: flex;
gap: 10px;
margin-top: 10px;
}
button {
font-size: 14px;
padding: 0 12px;
}
</style>
<template>
<view class="dropdown-wrapper">
<!-- 插槽:触发器 -->
<slot name="reference" :open="open" :close="close" :toggle="toggle">
<!-- 默认触发器(自动添加 data-dropdown-trigger) -->
<view v-if="!$slots.reference" class="dropdown-trigger-default" data-dropdown-trigger @tap="open">
点击选择 ▼
</view>
<!-- 自定义内容包裹层(自动识别触发) -->
<view v-else data-dropdown-trigger @tap="open">
<slot />
</view>
</slot>
<!-- 浮动下拉菜单 -->
<view
v-if="visible"
ref="dropdownRef"
class="zy-popup-dropdown-menu"
:style="{ top: `${top}px`, left: `${left}px`, transform: position }"
@touchmove.stop
>
<view class="dropdown-content">
<view
v-for="item in props.data"
:key="item[props.valueKey]"
class="dropdown-item"
:class="{ 'is-selected': props.modelValue === item[props.valueKey] }"
@tap="handleSelect(item)"
>
<text class="item-label">{{ item[props.labelKey] }}</text>
<text v-if="props.modelValue === item[props.valueKey]" class="icon-check"></text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, nextTick, onUnmounted } from 'vue'
// ----------------------
// Props 定义
// ----------------------
const props = defineProps({
// 数据源 [{ label: 'xxx', value: '1' }]
data: {
type: Array,
required: true
},
// 当前选中值(v-model)
modelValue: {
type: [String, Number, null],
default: null
},
// 显示字段名
labelKey: {
type: String,
default: 'name'
},
// 值字段名
valueKey: {
type: String,
default: 'value'
}
})
// ----------------------
// Emits
// ----------------------
const emit = defineEmits(['update:modelValue', 'change', 'open', 'close'])
// ----------------------
// 内部状态
// ----------------------
const visible = ref(false)
const top = ref(0)
const left = ref(0)
const position = ref('translateY(8px)')
const dropdownRef = ref(null)
let observer = null
// ----------------------
// 获取触发器位置(核心方法)
// ----------------------
function getTriggerRect() {
return new Promise(resolve => {
const query = uni.createSelectorQuery()
query.select('[data-dropdown-trigger]').boundingClientRect()
query.exec(res => {
const rect = res[0]
if (rect) {
console.log('[Dropdown] ✅ 成功获取触发器位置:', rect)
resolve(rect)
} else {
console.warn('[Dropdown] ❌ 未找到 [data-dropdown-trigger] 元素,请检查插槽结构')
resolve(null)
}
})
})
}
// ----------------------
// 打开下拉框
// ----------------------
const open = async () => {
if (visible.value) return
const rect = await getTriggerRect()
if (!rect) return
// 获取窗口高度
const res = await new Promise(resolve => {
uni.createSelectorQuery().selectViewport().scrollOffset().exec(resolve)
})
const scrollInfo = res[0]
const windowHeight = scrollInfo.windowHeight || scrollInfo.height
const maxHeight = 486
const needUpward = windowHeight - rect.bottom < maxHeight
// 设置位置
left.value = rect.left
top.value = needUpward ? rect.top - maxHeight - 8 : rect.bottom + 8
position.value = needUpward ? 'translateY(-8px)' : 'translateY(8px)'
visible.value = true
emit('open', props.modelValue)
nextTick(() => {
bindOutsideClickListener()
bindScrollListener()
})
}
// ----------------------
// 关闭 & 切换 & 选择
// ----------------------
const close = () => {
if (!visible.value) return
visible.value = false
emit('close')
removeListeners()
}
const toggle = () => {
visible.value ? close() : open()
}
const handleSelect = item => {
const value = item[props.valueKey]
const label = item[props.labelKey]
if (props.modelValue !== value) {
emit('update:modelValue', value)
emit('change', { value, label })
}
close()
}
// ----------------------
// 外部点击关闭
// ----------------------
const bindOutsideClickListener = () => {
const handler = e => {
// 阻止事件冒泡时也能监听到页面点击
const path = e.path || []
const isInside = path.some(node => node?.dataset?.dropdownTrigger)
if (!isInside) close()
}
uni.$on('onPageTap', handler)
observer = { ...observer, cleanupTap: () => uni.$off('onPageTap', handler) }
}
// ----------------------
// 页面滚动关闭
// ----------------------
const bindScrollListener = () => {
const scrollHandler = () => close()
uni.$on('onPageScroll', scrollHandler)
observer = { ...observer, cleanupScroll: () => uni.$off('onPageScroll', scrollHandler) }
}
// ----------------------
// 移除监听
// ----------------------
const removeListeners = () => {
observer?.cleanupTap?.()
observer?.cleanupScroll?.()
observer = null
}
const openByEvent = async e => {
const target = e.currentTarget || e.target
if (!target) {
console.error('[Dropdown] openByEvent 缺少 event 对象')
return
}
const rect = await new Promise(resolve => {
const query = uni.createSelectorQuery()
query.select(`#${target.id}`).boundingClientRect()
query.exec(res => resolve(res[0]))
})
if (!rect) {
// 回退:直接用 event 自带的信息
const { top, bottom, left, height } = e.detail?.offsetTop !== undefined ? e : target
const fallbackRect = {
top,
bottom: bottom || top + height,
left,
width: target.offsetWidth || 80,
height: target.offsetHeight || 40
}
console.warn('[Dropdown] 使用 event 数据回退定位', fallbackRect)
doOpen(fallbackRect)
} else {
doOpen(rect)
}
}
// 抽离打开逻辑
const doOpen = rect => {
const res = uni.getSystemInfoSync()
const windowHeight = res.windowHeight
const maxHeight = 486
const needUpward = windowHeight - rect.bottom < maxHeight
left.value = rect.left
top.value = needUpward ? rect.top - maxHeight - 8 : rect.bottom + 8
position.value = needUpward ? 'translateY(-8px)' : 'translateY(8px)'
visible.value = true
emit('open', props.modelValue)
nextTick(() => {
bindOutsideClickListener()
bindScrollListener()
})
}
// ----------------------
// 暴露方法给父组件调用
// ----------------------
defineExpose({ open, close, toggle })
// ----------------------
// 卸载清理
// ----------------------
onUnmounted(() => {
removeListeners()
})
</script>
<style scoped>
/* 整体容器 */
.dropdown-wrapper {
display: inline-block;
}
/* 默认触发器样式 */
.dropdown-trigger-default {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 16px;
background-color: #fff;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 15px;
color: #333;
}
/* 下拉菜单主体 */
.zy-popup-dropdown-menu {
position: fixed;
width: 215px;
max-height: 486px;
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 9999;
overflow: hidden;
}
/* 内容区可滚动 */
.dropdown-content {
height: 100%;
max-height: 486px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
/* 每一项 */
.dropdown-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
font-size: 15px;
color: #333333;
border-bottom: 1px solid #f5f5f5;
}
.dropdown-item:last-child {
border-bottom: none;
}
/* 选中项样式 */
.dropdown-item.is-selected {
color: #0f56d5;
font-weight: 500;
}
/* 文本自动换行 */
.item-label {
flex: 1;
word-break: break-word;
line-height: 1.4;
text-align: left;
}
/* 对号图标 */
.icon-check {
font-family: 'erda' !important; /* 可替换为 iconfont 字体 */
font-size: 16px;
margin-left: 8px;
color: #0f56d5;
}
</style>
报错zy-popup-dropdown-menu.js? [sm]:47 [Dropdown] ❌ 未找到 [data-dropdown-trigger] 元素,请检查插槽结构