cwg-droplist.vue 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. <template>
  2. <view class="cwg-droplist">
  3. <view class="cwg-droplist-trigger" @click.stop="toggleMenu" id="trigger-box">
  4. <slot></slot>
  5. </view>
  6. <!-- 使用 teleport 解决层级遮挡问题 (H5) -->
  7. <teleport to="body" :disabled="!isTeleportSupported">
  8. <view
  9. :id="portalId"
  10. class="cwg-droplist-portal"
  11. :class="{ 'is-show': isShow }"
  12. :style="{ zIndex }"
  13. @click.stop="closeMenu"
  14. >
  15. <view class="cwg-droplist-mask" @click.stop="closeMenu"></view>
  16. <view
  17. class="cwg-droplist-menu"
  18. :class="[`placement-${placement}`]"
  19. :style="menuStyle"
  20. @click.stop
  21. >
  22. <slot name="dropdown">
  23. <template v-if="menuList && menuList.length">
  24. <cwg-droplist-item
  25. v-for="(item, index) in menuList"
  26. :key="index"
  27. :command="item.command !== undefined ? item.command : (item.value !== undefined ? item.value : item)"
  28. :disabled="item.disabled"
  29. @click="handleMenuClick(item,index)"
  30. >
  31. {{ item.label || item.text || item.title || item }}
  32. </cwg-droplist-item>
  33. </template>
  34. </slot>
  35. </view>
  36. </view>
  37. </teleport>
  38. </view>
  39. </template>
  40. <script setup>
  41. import { ref, computed, getCurrentInstance, onUnmounted, nextTick } from 'vue'
  42. // 导入 cwg-droplist-item 以便在组件内部直接使用
  43. import CwgDroplistItem from './cwg-droplist-item.vue'
  44. const props = defineProps({
  45. // 传入的菜单列表,支持简单数组 ['A', 'B'] 或对象数组 [{ label: 'A', command: 'a', disabled: true, row: row }]
  46. menuList: {
  47. type: Array,
  48. default: () => []
  49. },
  50. // 菜单弹出位置,支持: bottom, bottom-start, bottom-end, top, top-start, top-end
  51. placement: {
  52. type: String,
  53. default: 'bottom'
  54. },
  55. // 距离触发元素的间距 (px)
  56. offset: {
  57. type: Number,
  58. default: 60
  59. },
  60. // z-index 层级
  61. zIndex: {
  62. type: Number,
  63. default: 9999
  64. },
  65. // 点击菜单项后是否自动隐藏菜单
  66. hideOnClick: {
  67. type: Boolean,
  68. default: true
  69. }
  70. })
  71. const emit = defineEmits(['visible-change', 'command', 'menuClick'])
  72. const instance = getCurrentInstance()
  73. const isShow = ref(false)
  74. const menuStyle = ref({})
  75. // 检测环境是否支持 Teleport
  76. const isTeleportSupported = computed(() => {
  77. // #ifdef H5
  78. return true
  79. // #endif
  80. return false
  81. })
  82. // 查询 DOM 节点信息
  83. const queryRect = (selector, context = instance.proxy) => {
  84. return new Promise((resolve) => {
  85. uni.createSelectorQuery()
  86. .in(context)
  87. .select(selector)
  88. .boundingClientRect(resolve)
  89. .exec()
  90. })
  91. }
  92. // 获取唯一标识符,用于 teleport 查询
  93. const portalId = 'droplist_' + Math.random().toString(36).substr(2, 9)
  94. // 展开/收起菜单
  95. const toggleMenu = async () => {
  96. if (isShow.value) {
  97. closeMenu()
  98. } else {
  99. await openMenu()
  100. }
  101. }
  102. // 展开菜单并计算位置
  103. const openMenu = async () => {
  104. const triggerRectList = await queryRect('.cwg-droplist-trigger')
  105. const triggerRect = Array.isArray(triggerRectList) ? triggerRectList[0] : triggerRectList
  106. if (!triggerRect) return
  107. // 预设菜单显示,获取菜单尺寸
  108. isShow.value = true
  109. emit('visible-change', true)
  110. await nextTick()
  111. let menuRect = null
  112. // #ifdef H5
  113. // 在 H5 且 teleport 生效时,需要通过全局原生 API 获取尺寸
  114. const el = document.querySelector(`#${portalId} .cwg-droplist-menu`)
  115. if (el) menuRect = el.getBoundingClientRect()
  116. // #endif
  117. // #ifndef H5
  118. let menuRectList = await queryRect('.cwg-droplist-menu', instance.proxy)
  119. menuRect = Array.isArray(menuRectList) ? menuRectList[0] : menuRectList
  120. // #endif
  121. if (!menuRect) return
  122. // 获取屏幕尺寸
  123. const sysInfo = uni.getSystemInfoSync()
  124. // 注意 windowHeight 可能受到键盘、导航栏影响,加上 windowTop 偏移更准确(特别是在 H5 中)
  125. const windowHeight = sysInfo.windowHeight
  126. const windowWidth = sysInfo.windowWidth
  127. const { left, right, top, bottom, width: triggerWidth, height: triggerHeight } = triggerRect
  128. const menuWidth = menuRect.width
  129. const menuHeight = menuRect.height
  130. let finalTop = 0
  131. let finalLeft = 0
  132. // 计算垂直位置(基于视口)
  133. if (props.placement.startsWith('top')) {
  134. finalTop = top - menuHeight - props.offset
  135. // 防溢出检测
  136. if (finalTop < 0) {
  137. finalTop = bottom + props.offset
  138. }
  139. } else { // bottom
  140. finalTop = bottom + props.offset
  141. // 防溢出检测:如果下方空间不够,且上方空间足够,向上翻转
  142. if (finalTop + menuHeight > windowHeight && top - menuHeight - props.offset > 0) {
  143. finalTop = top - menuHeight - props.offset
  144. }
  145. }
  146. // 计算水平位置
  147. if (props.placement.endsWith('start')) {
  148. finalLeft = left
  149. } else if (props.placement.endsWith('end')) {
  150. finalLeft = right - menuWidth
  151. } else { // center
  152. finalLeft = left + (triggerWidth / 2) - (menuWidth / 2)
  153. }
  154. // 水平防溢出检测
  155. if (finalLeft < 10) finalLeft = 10
  156. if (finalLeft + menuWidth > windowWidth - 10) {
  157. finalLeft = windowWidth - menuWidth - 10
  158. }
  159. menuStyle.value = {
  160. top: `${finalTop}px`,
  161. left: `${finalLeft}px`,
  162. opacity: 1,
  163. transform: 'scaleY(1)'
  164. }
  165. }
  166. // 关闭菜单
  167. const closeMenu = () => {
  168. isShow.value = false
  169. menuStyle.value = {
  170. ...menuStyle.value,
  171. opacity: 0,
  172. transform: 'scaleY(0)'
  173. }
  174. emit('visible-change', false)
  175. }
  176. // 提供给子组件 (menu-item) 调用的方法
  177. const handleItemClick = (command) => {
  178. emit('command', command)
  179. if (props.hideOnClick) {
  180. closeMenu()
  181. }
  182. }
  183. //
  184. const handleMenuClick = (item,index) => {
  185. emit('menuClick', { value: item, index })
  186. console.log('menuClick', item, index,'关闭',props.hideOnClick)
  187. if (props.hideOnClick) {
  188. closeMenu()
  189. }
  190. }
  191. // 暴露给外部或子组件的方法
  192. defineExpose({
  193. closeMenu,
  194. handleItemClick
  195. })
  196. </script>
  197. <style lang="scss" scoped>
  198. @import '@/uni.scss';
  199. .cwg-droplist {
  200. display: inline-block;
  201. position: relative;
  202. }
  203. .cwg-droplist-trigger {
  204. display: inline-block;
  205. cursor: pointer;
  206. }
  207. .cwg-droplist-portal {
  208. position: fixed;
  209. top: 0;
  210. /* 解决某些浏览器下的 H5 顶部导航栏导致 fixed 参考系下移的问题 */
  211. /* #ifdef H5 */
  212. top: var(--window-top);
  213. /* #endif */
  214. left: 0;
  215. width: 100vw;
  216. height: 100vh;
  217. pointer-events: none;
  218. visibility: hidden;
  219. &.is-show {
  220. pointer-events: auto;
  221. visibility: visible;
  222. }
  223. }
  224. .cwg-droplist-mask {
  225. position: absolute;
  226. top: 0;
  227. left: 0;
  228. width: 100%;
  229. height: 100%;
  230. background-color: transparent;
  231. }
  232. .cwg-droplist-menu {
  233. position: absolute;
  234. background-color: #fff;
  235. border: 1px solid #ebeef5;
  236. border-radius: px2rpx(4);
  237. box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
  238. padding: px2rpx(10) 0;
  239. min-width: px2rpx(100);
  240. opacity: 0;
  241. transform: scaleY(0);
  242. transition: opacity 0.3s, transform 0.3s;
  243. box-sizing: border-box;
  244. /* 动画基点设置 */
  245. &.placement-bottom,
  246. &.placement-bottom-start,
  247. &.placement-bottom-end {
  248. transform-origin: top center;
  249. }
  250. &.placement-top,
  251. &.placement-top-start,
  252. &.placement-top-end {
  253. transform-origin: bottom center;
  254. }
  255. /* 小箭头 (可选) */
  256. &::before {
  257. content: '';
  258. position: absolute;
  259. width: 0;
  260. height: 0;
  261. border: 6px solid transparent;
  262. }
  263. &.placement-bottom::before,
  264. &.placement-bottom-start::before,
  265. &.placement-bottom-end::before {
  266. top: -12px;
  267. border-bottom-color: #ebeef5;
  268. }
  269. &.placement-bottom::before { left: 50%; transform: translateX(-50%); }
  270. &.placement-bottom-start::before { left: 15px; }
  271. &.placement-bottom-end::before { right: 15px; }
  272. &.placement-top::before,
  273. &.placement-top-start::before,
  274. &.placement-top-end::before {
  275. bottom: -12px;
  276. border-top-color: #ebeef5;
  277. }
  278. &.placement-top::before { left: 50%; transform: translateX(-50%); }
  279. &.placement-top-start::before { left: 15px; }
  280. &.placement-top-end::before { right: 15px; }
  281. }
  282. </style>