cwg-dropdown.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. <template>
  2. <view class="cwg-dropdown-root" :data-dropdown-id="dropdownUid">
  3. <view class="cwg-dropdown" :style="customStyle" @click="click">
  4. <slot></slot>
  5. </view>
  6. <view class="cwg-dropdown-menu">
  7. <view class="cwg-dropdown-menu-container" :style="[layout]" @click.stop>
  8. <slot name="menu">
  9. <view class="menu">
  10. <slot name="btn"></slot>
  11. <view class="menu-item" :class="{ active: props.showActive && isActive(item), disabled: item.disabled || false }"
  12. v-for="(item, idx) in menuList" :key="idx"
  13. @click="menuClick(item, idx)">
  14. <view>{{ item.label || item }}</view>
  15. <!-- <view v-if="props.showActive && isActive(item)" class="active-icon">✓</view>-->
  16. </view>
  17. </view>
  18. </slot>
  19. </view>
  20. </view>
  21. <view class="cwg-dropdown-mask" :class="{ 'cwg-dropdown-mask-show': maskShow }" />
  22. </view>
  23. </template>
  24. <script setup>
  25. import { ref, reactive, nextTick, onMounted, onUnmounted, watch, getCurrentInstance } from 'vue'
  26. import { str2px, commonProps } from '@/uni_modules/x-tools/tools/com.js'
  27. const DROPDOWN_CLOSE_EVENT = 'cwg-dropdown:close-others'
  28. const DROPDOWN_CLOSE_ALL_EVENT = 'cwg-dropdown:close-all'
  29. const instance = getCurrentInstance()
  30. const dropdownUid = `cwg-dropdown-${Math.random().toString(36).slice(2, 9)}`
  31. // 合并 props
  32. const props = defineProps({
  33. ...commonProps,
  34. menuList: {
  35. type: Object,
  36. default: () => ['菜单1', '菜单2', '菜单3']
  37. },
  38. menuStyle: {
  39. type: Object,
  40. default: () => ({})
  41. },
  42. interspace: {
  43. type: [String, Number],
  44. default: '10rpx'
  45. },
  46. // 是否显示选中样式
  47. showActive: {
  48. type: Boolean,
  49. default: false
  50. },
  51. // 当前选中项的 key(用于匹配 menuList 中的项)
  52. activeKey: {
  53. type: [String, Number],
  54. default: ''
  55. }
  56. })
  57. const emit = defineEmits(['open', 'close', 'change', 'menuClick'])
  58. // 判断菜单项是否为选中状态
  59. const isActive = (item) => {
  60. if (!props.showActive || !props.activeKey) return false
  61. // 支持多种 key 字段匹配
  62. const itemKey = item.key !== undefined ? item.key :
  63. item.value !== undefined ? item.value :
  64. item.sysCode !== undefined ? item.sysCode :
  65. item.type !== undefined ? item.type : ''
  66. return itemKey === props.activeKey
  67. }
  68. // 响应式数据
  69. const maskShow = ref(false)
  70. const windowInfo = reactive({
  71. width: 0,
  72. height: 0
  73. })
  74. const layout = reactive({})
  75. const innerInterspace = ref(0)
  76. const TAP_MOVE_THRESHOLD = 10
  77. const OPEN_GUARD_MS = 300
  78. let openTimestamp = 0
  79. let outsideListenersBound = false
  80. const touchState = {
  81. startX: 0,
  82. startY: 0,
  83. moved: false,
  84. }
  85. function getTouchPoint(e) {
  86. const touch = e.touches?.[0] || e.changedTouches?.[0]
  87. return touch ? { x: touch.clientX, y: touch.clientY } : null
  88. }
  89. function queryInComponent(selector) {
  90. return new Promise((resolve) => {
  91. uni.createSelectorQuery()
  92. .in(instance?.proxy)
  93. .select(selector)
  94. .boundingClientRect(resolve)
  95. .exec()
  96. })
  97. }
  98. function isInsideCurrentDropdown(target) {
  99. if (!target?.closest) return false
  100. return !!target.closest(`[data-dropdown-id="${dropdownUid}"]`)
  101. }
  102. const OVERLAY_SELECTORS = [
  103. '.cwg-dialog',
  104. '.crm-popup',
  105. '.uni-popup',
  106. '.uni-popup__wrapper',
  107. '.uni-modal',
  108. '.uni-mask',
  109. '.dialog-footer',
  110. '.confirm-title',
  111. '.confirm-content',
  112. ]
  113. function isInsideOverlay(target) {
  114. if (!target?.closest) return false
  115. return OVERLAY_SELECTORS.some((selector) => target.closest(selector))
  116. }
  117. function onOutsideTouchStart(e) {
  118. if (!maskShow.value) return
  119. const point = getTouchPoint(e)
  120. if (!point) return
  121. touchState.startX = point.x
  122. touchState.startY = point.y
  123. touchState.moved = false
  124. }
  125. function onOutsideTouchMove(e) {
  126. if (!maskShow.value) return
  127. const point = getTouchPoint(e)
  128. if (!point) return
  129. if (
  130. Math.abs(point.x - touchState.startX) > TAP_MOVE_THRESHOLD
  131. || Math.abs(point.y - touchState.startY) > TAP_MOVE_THRESHOLD
  132. ) {
  133. touchState.moved = true
  134. }
  135. }
  136. function onOutsideTouchEnd(e) {
  137. if (!maskShow.value) return
  138. if (Date.now() - openTimestamp < OPEN_GUARD_MS) return
  139. if (touchState.moved) return
  140. if (isInsideCurrentDropdown(e.target) || isInsideOverlay(e.target)) return
  141. e.preventDefault()
  142. e.stopPropagation()
  143. close()
  144. }
  145. function onOutsideClick(e) {
  146. if (!maskShow.value) return
  147. if (Date.now() - openTimestamp < OPEN_GUARD_MS) return
  148. if (isInsideCurrentDropdown(e.target) || isInsideOverlay(e.target)) return
  149. e.preventDefault()
  150. e.stopPropagation()
  151. close()
  152. }
  153. function bindOutsideListeners() {
  154. if (outsideListenersBound || typeof document === 'undefined') return
  155. outsideListenersBound = true
  156. document.addEventListener('touchstart', onOutsideTouchStart, true)
  157. document.addEventListener('touchmove', onOutsideTouchMove, { capture: true, passive: true })
  158. document.addEventListener('touchend', onOutsideTouchEnd, { capture: true, passive: false })
  159. document.addEventListener('click', onOutsideClick, true)
  160. }
  161. function unbindOutsideListeners() {
  162. if (!outsideListenersBound || typeof document === 'undefined') return
  163. outsideListenersBound = false
  164. document.removeEventListener('touchstart', onOutsideTouchStart, true)
  165. document.removeEventListener('touchmove', onOutsideTouchMove, true)
  166. document.removeEventListener('touchend', onOutsideTouchEnd, true)
  167. document.removeEventListener('click', onOutsideClick, true)
  168. }
  169. watch(maskShow, (visible) => {
  170. if (visible) {
  171. bindOutsideListeners()
  172. } else {
  173. unbindOutsideListeners()
  174. }
  175. })
  176. // 获取系统信息
  177. const getSystemInfo = () => {
  178. return new Promise((resolve) => {
  179. if (windowInfo.width > 0) {
  180. resolve(windowInfo)
  181. } else {
  182. uni.getSystemInfo({
  183. success: (res) => {
  184. windowInfo.width = res.windowWidth
  185. windowInfo.height = res.windowHeight
  186. resolve(windowInfo)
  187. },
  188. fail: () => {
  189. setTimeout(() => getSystemInfo().then(resolve), 100)
  190. }
  191. })
  192. }
  193. })
  194. }
  195. // 点击触发器
  196. const click = async (e) => {
  197. e.stopPropagation()
  198. if (maskShow.value) {
  199. close()
  200. return
  201. }
  202. uni.$emit(DROPDOWN_CLOSE_EVENT, dropdownUid)
  203. await getSystemInfo()
  204. const triggerRect = await queryInComponent('.cwg-dropdown')
  205. if (!triggerRect) return
  206. const tempStyle = {
  207. transform: 'scaleY(1)',
  208. visibility: 'hidden',
  209. top: '-9999px',
  210. left: '-9999px',
  211. transition: 'none'
  212. }
  213. Object.assign(layout, tempStyle)
  214. await nextTick()
  215. const menuRect = await queryInComponent('.cwg-dropdown-menu-container')
  216. if (!menuRect) {
  217. Object.keys(layout).forEach(key => delete layout[key])
  218. layout.transform = 'scaleY(0)'
  219. return
  220. }
  221. const { width: winWidth } = windowInfo
  222. const { left, right, bottom } = triggerRect
  223. const interspaceVal = innerInterspace.value
  224. const finalLayout = {
  225. transition: 'transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
  226. transform: 'scaleY(1)',
  227. top: `10px`
  228. }
  229. if (left + menuRect.width < winWidth) {
  230. finalLayout.left = `-110px`
  231. } else {
  232. const val = winWidth - right
  233. finalLayout.right = `${val > 0 ? val : 0}px`
  234. }
  235. Object.keys(layout).forEach(key => delete layout[key])
  236. Object.assign(layout, finalLayout)
  237. openTimestamp = Date.now()
  238. maskShow.value = true
  239. emit('open')
  240. emit('change', true)
  241. }
  242. const menuClick = (value, index) => {
  243. if (value.disabled) return
  244. emit('menuClick', { value, index })
  245. close()
  246. }
  247. const close = () => {
  248. if (!maskShow.value && Object.keys(layout).length === 0) return
  249. maskShow.value = false
  250. Object.keys(layout).forEach(key => delete layout[key])
  251. layout.transform = 'scaleY(0)'
  252. emit('close')
  253. emit('change', false)
  254. }
  255. function onCloseOthers(activeUid) {
  256. if (activeUid !== dropdownUid) {
  257. close()
  258. }
  259. }
  260. onMounted(() => {
  261. getSystemInfo()
  262. innerInterspace.value = str2px(props.interspace)
  263. uni.$on(DROPDOWN_CLOSE_EVENT, onCloseOthers)
  264. uni.$on(DROPDOWN_CLOSE_ALL_EVENT, close)
  265. uni.$on('logout', close)
  266. })
  267. onUnmounted(() => {
  268. uni.$off('logout', close)
  269. uni.$off(DROPDOWN_CLOSE_EVENT, onCloseOthers)
  270. uni.$off(DROPDOWN_CLOSE_ALL_EVENT, close)
  271. unbindOutsideListeners()
  272. })
  273. // 暴露方法
  274. defineExpose({
  275. close
  276. })
  277. </script>
  278. <style lang="scss" scoped>
  279. @import '@/uni.scss';
  280. .cwg-dropdown {
  281. width: fit-content;
  282. position: relative;
  283. overflow: hidden;
  284. }
  285. .cwg-dropdown-menu {
  286. position: relative;
  287. z-index: 999;
  288. pointer-events: none;
  289. }
  290. .cwg-dropdown-mask {
  291. position: fixed;
  292. top: 0;
  293. left: 0;
  294. width: 100vw;
  295. height: 100vh;
  296. z-index: 998;
  297. transition: all 0.3s;
  298. opacity: 0;
  299. pointer-events: none;
  300. background-color: rgba(0, 0, 0, 0);
  301. }
  302. .cwg-dropdown-mask-show {
  303. opacity: 1;
  304. pointer-events: none;
  305. touch-action: pan-y;
  306. -webkit-touch-callout: none;
  307. }
  308. .cwg-dropdown-menu-container {
  309. position: absolute;
  310. transform-origin: top;
  311. transform: scaleY(0);
  312. pointer-events: auto;
  313. .menu {
  314. position: relative;
  315. --bs-bg-opacity: 1;
  316. background-color: rgba(var(--bs-body-bg-rgb), var(--bs-bg-opacity)) !important;
  317. box-shadow: 0px 5px 10px 0px rgba(0, 0, 0, 0.05);
  318. border: 1px solid var(--bs-border-color);
  319. border-radius: px2rpx(4);
  320. overflow: hidden;
  321. .menu-item {
  322. min-width: px2rpx(150);
  323. display: flex;
  324. align-items: center;
  325. padding: px2rpx(10) px2rpx(16);
  326. font-size: px2rpx(14);
  327. line-height: px2rpx(30);
  328. color: var(--bs-emphasis-color);
  329. transition: background 0.2s;
  330. box-sizing: border-box;
  331. cursor: pointer;
  332. &:last-child {
  333. border-bottom: none;
  334. }
  335. text {
  336. flex: 1;
  337. line-height: 1.4;
  338. }
  339. &:hover {
  340. background-color: rgba(0, 0, 0, 0.05);
  341. }
  342. &:active {
  343. background-color: rgba(0, 0, 0, 0.05);
  344. }
  345. &.active {
  346. background-color: rgba(234, 0, 42, 0.1);
  347. color: #ea002a;
  348. }
  349. &.disabled {
  350. cursor: not-allowed;
  351. opacity: 0.5;
  352. }
  353. }
  354. }
  355. }
  356. </style>