<template>
<view class="dropdown-wrapper">
<!-- 插槽:触发器 -->
<slot name="reference" :open="open" :close="close" :toggle="toggle">
<!-- 默认触发器 -->
<view
class="dropdown-trigger-default"
data-dropdown-trigger
@tap="open"
>
点击选择 ▼
</view>
</slot>
<!-- 浮动下拉菜单 -->
<view
v-if="visible"
ref="dropdownRef"
class="zy-popup-dropdown-menu"
:style="{ top: `${top}px`, right: `${right}px`, transform: positionStyle || position }"
@touchmove.stop
>
<!-- 使用 scroll-view 实现可靠滚动 -->
<scroll-view
class="dropdown-content"
:style="{ maxHeight: props.maxHeight + 'px' }"
scroll-y
@touchstart="onContentTouchStart"
@touchmove="onContentTouchMove"
>
<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>
<uni-icons
v-show="props.modelValue === item[props.valueKey]"
class="icon-check"
:type="checkIcon"
color="#0f56d5"
/>
</view>
</scroll-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, Boolean, null],
default: null
},
// 显示字段名
labelKey: {
type: String,
default: 'name'
},
// 值字段名
valueKey: {
type: String,
default: 'value'
},
// 触发器元素的 ID(保留兼容性,但不再强制使用)
trigger: {
type: String,
default: '',
required: true
},
// 选中图标
checkIcon: {
type: String,
default: 'checkmarkempty'
},
// 页面滚动是否关闭下拉框
scrollClose: {
type: Boolean,
default: true
},
// 最大高度(px)
maxHeight: {
type: Number,
default: 486
},
// 自定义定位 transform
positionStyle: {
type: String,
default: ''
}
})
// ----------------------
// Emits
// ----------------------
const emit = defineEmits(['update:modelValue', 'change', 'open', 'close'])
// ----------------------
// 内部状态
// ----------------------
const visible = ref(false)
const top = ref(0)
const left = ref(0)
const right = ref(12)
const position = ref('translateY(8px)')
const dropdownRef = ref(null)
let observer = null
let startY, startScrollTop
// ----------------------
// 触摸事件处理(用于防止滚动穿透)
// ----------------------
const onContentTouchStart = e => {
const el = e.currentTarget
startY = e.touches?.[0]?.clientY || 0
startScrollTop = el?.scrollTop || 0
}
const onContentTouchMove = e => {
const el = e.currentTarget
if (!el) return
const endY = e.touches?.[0]?.clientY
if (!endY) return
const direction = startY - endY > 0 ? 'down' : 'up'
// 到达顶部且向上滑动 → 允许页面滚动
if (startScrollTop <= 0 && direction === 'up') return
// 到达底部且向下滑动 → 允许外层滚动
if (el.scrollHeight - el.scrollTop <= el.clientHeight + 1 && direction === 'down') return
// 否则阻止默认行为
e.preventDefault()
}
// ----------------------
// 【关键修改】不再依赖 id 查询,改为从事件获取 rect
// ----------------------
let lastTriggerRect = null // 缓存最后一次触发器位置
const open = async (e) => {
if (visible.value) {
close()
return
}
// ✅ 使用事件对象获取触发器位置
if (e && e.target) {
const view = uni.createSelectorQuery()
view
.select(`#${e.target.id || ''}, [data-dropdown-trigger]`)
.boundingClientRect(rect => {
if (rect) {
lastTriggerRect = rect
doOpen(rect)
} else {
console.warn('[Dropdown] 无法获取触发器位置,请检查结构')
}
})
.exec()
} else {
// 回退:尝试查 props.trigger(兼容旧写法)
uni
.createSelectorQuery()
.select('#' + props.trigger)
.boundingClientRect(rect => {
if (rect) {
lastTriggerRect = rect
doOpen(rect)
}
})
.exec()
}
}
// ----------------------
// 打开逻辑保持不变
// ----------------------
const doOpen = rect => {
const res = uni.getSystemInfoSync()
const windowHeight = Number(res.windowHeight)
const windowWidth = Number(res.windowWidth)
const itemHeight = 46
const menuHeight = Math.min(props.maxHeight, (props.data.length + 1) * itemHeight)
const menuWidth = 215
const gap = 8
const spaceBelow = windowHeight - rect.bottom
const spaceAbove = rect.top
const isBothInsufficient = spaceAbove < menuHeight && spaceBelow < menuHeight
let finalTop, finalLeft, finalTransform
if (isBothInsufficient) {
finalTop = windowHeight / 2
finalTransform = 'translate(0, -50%)'
finalLeft = windowWidth - menuWidth - 12
if (finalLeft < 0) finalLeft = 0
} else {
const needUpward = spaceBelow < menuHeight
finalTop = needUpward ? rect.top - menuHeight - gap : rect.bottom + gap
finalTransform = needUpward ? 'translateY(8px)' : 'translateY(-8px)'
finalLeft = windowWidth - menuWidth - 12
if (finalLeft < 0) finalLeft = 0
}
top.value = finalTop
left.value = finalLeft
position.value = finalTransform
visible.value = true
emit('open', props.modelValue, props.trigger)
nextTick(() => {
bindOutsideClickListener()
bindScrollListener()
})
}
// ----------------------
// 关闭 & 切换 & 选择
// ----------------------
const close = () => {
if (!visible.value) return
visible.value = false
emit('close', props.trigger)
removeListeners()
}
const toggle = () => {
visible.value ? close() : open()
}
const handleSelect = item => {
const value = item[props.valueKey]
const label = item[props.labelKey]
const selected = { [props.valueKey]: value, [props.labelKey]: label }
if (props.modelValue !== value) {
emit('update:modelValue', value, props.trigger)
emit('change', selected, props.trigger)
}
close()
}
// ----------------------
// 外部点击关闭(增强判断)
// ----------------------
const bindOutsideClickListener = () => {
const handler = e => {
const path = e.path || []
const isInside = path.some(node =>
node === dropdownRef.value?.$el ||
node?.classList?.contains('zy-popup-dropdown-menu') ||
node?.dataset?.dropdownTrigger
)
if (!isInside) close()
}
uni.$on('onPageTap', handler)
observer = { ...observer, cleanupTap: () => uni.$off('onPageTap', handler) }
}
// ----------------------
// 页面滚动关闭
// ----------------------
const bindScrollListener = () => {
const scrollHandler = () => {
if (!visible.value) return
if (props.scrollClose) {
close()
} else if (lastTriggerRect) {
reposition(lastTriggerRect)
}
}
uni.$on('onPageScroll', scrollHandler)
observer = {
...observer,
cleanupScroll: () => uni.$off('onPageScroll', scrollHandler)
}
}
// ----------------------
// 重新定位(简化)
// ----------------------
const reposition = () => {
if (lastTriggerRect) {
doOpen(lastTriggerRect)
}
}
// ----------------------
// 移除监听
// ----------------------
const removeListeners = () => {
observer?.cleanupTap?.()
observer?.cleanupScroll?.()
observer = null
}
// ----------------------
// 暴露方法
// ----------------------
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;
cursor: pointer;
}
/* 下拉菜单主体 */
.zy-popup-dropdown-menu {
position: fixed;
width: 215px;
background-color: #ffffff;
border-radius: 8px;
z-index: 9999;
display: flex;
flex-direction: column;
box-shadow:
0 4px 5px -3px rgba(0, 0, 0, 0.08),
0 8px 12px 1px rgba(0, 0, 0, 0.04),
0 3px 15px 3px rgba(0, 0, 0, 0.05);
}
/* 内容区可滚动 */
.dropdown-content {
max-height: 486px;
}
/* 每一项 */
.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;
font-size: 16px;
margin-left: 8px;
color: #0f56d5;
}
</style>
当前组件被组件套用后依旧无法正常展示
最新发布