|
|
@@ -0,0 +1,314 @@
|
|
|
+<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="emit('menuClick', { value: 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()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 暴露给外部或子组件的方法
|
|
|
+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>
|