cwg-droplist.vue 7.9 KB

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