cwg-droplist.vue 8.1 KB

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