| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322 |
- <template>
- <view class="cwg-droplist">
- <view class="cwg-droplist-trigger" @click.stop="toggleMenu" id="trigger-box">
- <slot></slot>
- </view>
- <!-- 使用 teleport 解决层级遮挡问题 (H5) -->
- <teleport to="body" :disabled="!isTeleportSupported">
- <view
- :id="portalId"
- class="cwg-droplist-portal"
- :class="{ 'is-show': isShow }"
- :style="{ zIndex }"
- @click.stop="closeMenu"
- >
- <view class="cwg-droplist-mask" @click.stop="closeMenu"></view>
- <view
- class="cwg-droplist-menu"
- :class="[`placement-${placement}`]"
- :style="menuStyle"
- @click.stop
- >
- <slot name="dropdown">
- <template v-if="menuList && menuList.length">
- <cwg-droplist-item
- v-for="(item, index) in menuList"
- :key="index"
- :command="item.command !== undefined ? item.command : (item.value !== undefined ? item.value : item)"
- :disabled="item.disabled"
- @click="handleMenuClick(item,index)"
- >
- {{ item.label || item.text || item.title || item }}
- </cwg-droplist-item>
- </template>
- </slot>
- </view>
- </view>
- </teleport>
- </view>
- </template>
- <script setup>
- import { ref, computed, getCurrentInstance, onUnmounted, nextTick } from 'vue'
- // 导入 cwg-droplist-item 以便在组件内部直接使用
- import CwgDroplistItem from './cwg-droplist-item.vue'
- const props = defineProps({
- // 传入的菜单列表,支持简单数组 ['A', 'B'] 或对象数组 [{ label: 'A', command: 'a', disabled: true, row: row }]
- menuList: {
- type: Array,
- default: () => []
- },
- // 菜单弹出位置,支持: bottom, bottom-start, bottom-end, top, top-start, top-end
- placement: {
- type: String,
- default: 'bottom'
- },
- // 距离触发元素的间距 (px)
- offset: {
- type: Number,
- default: 60
- },
- // z-index 层级
- zIndex: {
- type: Number,
- default: 9999
- },
- // 点击菜单项后是否自动隐藏菜单
- hideOnClick: {
- type: Boolean,
- default: true
- }
- })
- const emit = defineEmits(['visible-change', 'command', 'menuClick'])
- const instance = getCurrentInstance()
- const isShow = ref(false)
- const menuStyle = ref({})
- // 检测环境是否支持 Teleport
- const isTeleportSupported = computed(() => {
- // #ifdef H5
- return true
- // #endif
- return false
- })
- // 查询 DOM 节点信息
- const queryRect = (selector, context = instance.proxy) => {
- return new Promise((resolve) => {
- uni.createSelectorQuery()
- .in(context)
- .select(selector)
- .boundingClientRect(resolve)
- .exec()
- })
- }
- // 获取唯一标识符,用于 teleport 查询
- const portalId = 'droplist_' + Math.random().toString(36).substr(2, 9)
- // 展开/收起菜单
- const toggleMenu = async () => {
- if (isShow.value) {
- closeMenu()
- } else {
- await openMenu()
- }
- }
- // 展开菜单并计算位置
- const openMenu = async () => {
- const triggerRectList = await queryRect('.cwg-droplist-trigger')
- const triggerRect = Array.isArray(triggerRectList) ? triggerRectList[0] : triggerRectList
- if (!triggerRect) return
- // 预设菜单显示,获取菜单尺寸
- isShow.value = true
- emit('visible-change', true)
-
- await nextTick()
- let menuRect = null
- // #ifdef H5
- // 在 H5 且 teleport 生效时,需要通过全局原生 API 获取尺寸
- const el = document.querySelector(`#${portalId} .cwg-droplist-menu`)
- if (el) menuRect = el.getBoundingClientRect()
- // #endif
- // #ifndef H5
- let menuRectList = await queryRect('.cwg-droplist-menu', instance.proxy)
- menuRect = Array.isArray(menuRectList) ? menuRectList[0] : menuRectList
- // #endif
- if (!menuRect) return
- // 获取屏幕尺寸
- const sysInfo = uni.getSystemInfoSync()
- // 注意 windowHeight 可能受到键盘、导航栏影响,加上 windowTop 偏移更准确(特别是在 H5 中)
- const windowHeight = sysInfo.windowHeight
- const windowWidth = sysInfo.windowWidth
- const { left, right, top, bottom, width: triggerWidth, height: triggerHeight } = triggerRect
- const menuWidth = menuRect.width
- const menuHeight = menuRect.height
- let finalTop = 0
- let finalLeft = 0
- // 计算垂直位置(基于视口)
- if (props.placement.startsWith('top')) {
- finalTop = top - menuHeight - props.offset
- // 防溢出检测
- if (finalTop < 0) {
- finalTop = bottom + props.offset
- }
- } else { // bottom
- finalTop = bottom + props.offset
- // 防溢出检测:如果下方空间不够,且上方空间足够,向上翻转
- if (finalTop + menuHeight > windowHeight && top - menuHeight - props.offset > 0) {
- finalTop = top - menuHeight - props.offset
- }
- }
- // 计算水平位置
- if (props.placement.endsWith('start')) {
- finalLeft = left
- } else if (props.placement.endsWith('end')) {
- finalLeft = right - menuWidth
- } else { // center
- finalLeft = left + (triggerWidth / 2) - (menuWidth / 2)
- }
- // 水平防溢出检测
- if (finalLeft < 10) finalLeft = 10
- if (finalLeft + menuWidth > windowWidth - 10) {
- finalLeft = windowWidth - menuWidth - 10
- }
- menuStyle.value = {
- top: `${finalTop}px`,
- left: `${finalLeft}px`,
- opacity: 1,
- transform: 'scaleY(1)'
- }
- }
- // 关闭菜单
- const closeMenu = () => {
- isShow.value = false
- menuStyle.value = {
- ...menuStyle.value,
- opacity: 0,
- transform: 'scaleY(0)'
- }
- emit('visible-change', false)
- }
- // 提供给子组件 (menu-item) 调用的方法
- const handleItemClick = (command) => {
- emit('command', command)
- if (props.hideOnClick) {
- closeMenu()
- }
- }
- //
- const handleMenuClick = (item,index) => {
- emit('menuClick', { value: item, index })
- console.log('menuClick', item, index,'关闭',props.hideOnClick)
- if (props.hideOnClick) {
- closeMenu()
- }
- }
- // 暴露给外部或子组件的方法
- defineExpose({
- closeMenu,
- handleItemClick
- })
- </script>
- <style lang="scss" scoped>
- @import '@/uni.scss';
- .cwg-droplist {
- display: inline-block;
- position: relative;
- }
- .cwg-droplist-trigger {
- display: inline-block;
- cursor: pointer;
- }
- .cwg-droplist-portal {
- position: fixed;
- top: 0;
- /* 解决某些浏览器下的 H5 顶部导航栏导致 fixed 参考系下移的问题 */
- /* #ifdef H5 */
- top: var(--window-top);
- /* #endif */
- left: 0;
- width: 100vw;
- height: 100vh;
- pointer-events: none;
- visibility: hidden;
- &.is-show {
- pointer-events: auto;
- visibility: visible;
- }
- }
- .cwg-droplist-mask {
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- background-color: transparent;
- }
- .cwg-droplist-menu {
- position: absolute;
- background-color: #fff;
- border: 1px solid #ebeef5;
- border-radius: px2rpx(4);
- box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
- padding: px2rpx(10) 0;
- min-width: px2rpx(100);
- opacity: 0;
- transform: scaleY(0);
- transition: opacity 0.3s, transform 0.3s;
- box-sizing: border-box;
- /* 动画基点设置 */
- &.placement-bottom,
- &.placement-bottom-start,
- &.placement-bottom-end {
- transform-origin: top center;
- }
- &.placement-top,
- &.placement-top-start,
- &.placement-top-end {
- transform-origin: bottom center;
- }
- /* 小箭头 (可选) */
- &::before {
- content: '';
- position: absolute;
- width: 0;
- height: 0;
- border: 6px solid transparent;
- }
- &.placement-bottom::before,
- &.placement-bottom-start::before,
- &.placement-bottom-end::before {
- top: -12px;
- border-bottom-color: #ebeef5;
- }
- &.placement-bottom::before { left: 50%; transform: translateX(-50%); }
- &.placement-bottom-start::before { left: 15px; }
- &.placement-bottom-end::before { right: 15px; }
- &.placement-top::before,
- &.placement-top-start::before,
- &.placement-top-end::before {
- bottom: -12px;
- border-top-color: #ebeef5;
- }
-
- &.placement-top::before { left: 50%; transform: translateX(-50%); }
- &.placement-top-start::before { left: 15px; }
- &.placement-top-end::before { right: 15px; }
- }
- </style>
|