美文网首页让前端飞web前端技术分享前端
vue3 手写鼠标hover元素显示详情的组件,支持click,

vue3 手写鼠标hover元素显示详情的组件,支持click,

作者: 阿巳交不起水电费 | 来源:发表于2023-09-07 11:39 被阅读0次

    前言

    首先为什么不直接使用css的hover?
    因为有多个地方都有这个,每个元素都包裹一个弹窗可以是可以,但是dom就堆积太多了。而且若父元素设置overflow:hidden怎么办?

    因此还是决定写一个hover显示弹窗的组件,咋一看,写这个不是简简单单?然而写完了发现有个严重问题。鼠标滚轮滚动过快的时候不会触发鼠标移入事件,无论是mousenter还是pointerenter,导致要么弹窗出不来,要么弹窗位置错误的情况,严重影响体验!没办法查了半天也没查到怎么处理,最后只能优化下用户体验了,比如鼠标滚动的时候隐藏弹窗,鼠标在目标元素move的时候重新矫正弹窗位置。

    本来是定位的hover显示弹窗的组件,后面没办法又需要支持click,然后就抽空重新更新了下这篇博客。。。

    注意:

    1、未考虑超出浏览器显示范围的边界情况,自行调整代码。
    2、目前效果是在目标元素右侧垂直居中显示,其他位置自行调整代码。
    3、支持设置水平、垂直方向的偏移量。
    4、支持 pointerenter | click两种触发弹窗的方式。

    特别说明:对于click方式,需要额外参数【类或id选择器或者元素节点名称,eg: 类:.app | id: #app | 元素节点名称 body】。因为目标元素有子元素时,点击子元素也会触发click,那么e.target就变成了子元素,导致定位也是相对于子元素定位的,而不是目标元素!因此内部写了个方法根据第二个参数循环查找节点的父节点,直到找到我们想要的目标元素。

    一、效果如下:

    image.png

    二、vue版本为 "vue": "^3.3.0",

    三、文件目录:

    image.png

    四、源码为:

    hoverTip/index.vue

    <!-- 
      author:yangfeng
      date:2023/09/11
    
      鼠标hover或click显示弹窗:使用时目标元素需要绑定 pointerenter | click 事件为下面导出的 showTip 事件 
    
      支持:pointerenter:鼠标指针移入目标元素显示当前弹窗
           click:点击显示目标元素当前弹窗
           可设置弹窗偏移量
    
      注意:trigger为click时,showTip第二个参数必传,防止目标元素包含子元素,导致弹窗是相对子元素定位的而不是目标元素定位。【类或id选择器或者元素节点名称,eg: 类:.app | id: #app | 元素节点名称 body】
    -->
    <template>
      <div class="hoverTip-box" ref="refDom" tabindex="-1">
        <slot>
          <div v-html="title"></div>
        </slot>
      </div>
    </template>
    <script>
    export default {
      name: 'hoverTip',
    }
    </script>
    <script setup>
    import { ref, onUnmounted, onMounted, nextTick } from 'vue'
    import { findNodeFromCurrent } from './index'
    const props = defineProps({
      title: {
        type: String,
        default: '默认内容'
      },
      info: {
        type: String,
        default: ''
      },
      trigger: { // 触发方式 pointerenter:鼠标指针移入 | click:点击 【注意:对应目标元素触发方式也要改】
        type: String,
        default: 'pointerenter',
        validator(value) {
            // The value must match one of these strings
            return ['pointerenter', 'click'].includes(value)
        }
      },
      offsetX: { // 弹窗显示水平偏移量,单位px
        type: Number,
        default: 0
      },
      offsetY: { // 弹窗显示垂直偏移量,单位px
        type: Number,
        default: 0
      }
    })
    
    const emit = defineEmits([
      'clickEnd' // trigger click 特有事件: 点击事件结束,关闭弹窗【可在调用组件中消除点击的某些副作用】,比如,失去焦点的时候 - 鼠标事件因为是自动触发的,不用额外判断
    ])
    
    const refDom = ref(null)
    
    // 转为数字
    const toNumber = (val) => {
      if (Number(val).toString() === 'NaN') return 0
      return Number(val)
    }
    const getOffset = () => {
      return {
        x: toNumber(props.offsetX),
        y: toNumber(props.offsetY)
      }
    }
    
    let addMoveEvented = false // 是否已经绑定鼠标移动事件
    let target = null // 鼠标移入的目标dom
    let focusIn = false // 是否focus弹窗
    
    /**
     * 关闭弹窗,消除副作用
     * @param {*} clear 是否清除其他事件
     */
    const hideTip = (clear = true) => {
      // console.log('hideTip', target, clear)
      refDom.value.style.display = 'none'
      if (clear) {
        addMoveEvented = false
        target && target.removeEventListener('pointermove', pointerenterHandle)
        target = null
        focusIn = false
      }
    }
    
    // 计算弹窗位置的核心方法
    const calCore = (e, eventType) => {
      if (!target) return hideTip()
      // console.log('pointerenterHandle',target, eventType)
      // e.preventDefault && e.preventDefault()
      // target.setPointerCapture(e.pointerId);
      let rect = target.getBoundingClientRect()
      // console.log(target, rect, 'mouseenter')
    
      // 计算位置
      let offset = getOffset()
      refDom.value.style.display = 'flex'
      refDom.value.style.left = target.offsetWidth + rect.x + offset.x + 'px'
      refDom.value.style.top = (target.offsetHeight / 2 + rect.y) - refDom.value.offsetHeight / 2 + offset.y + 'px' // 目标中心位置 - 弹窗一半高度
    }
    
    // 弹窗显示并设置弹窗位置
    let pointerenterHandle = (e, eventType) => {
      if (!target) return hideTip()
      calCore(e, eventType)
      eventConfig[props.trigger].bindEvent()
    }
    
    // 事件配置
    const eventConfig = {
      // 指针移入目标触发
      'pointerenter': {
        // 初始化
        init: () => { },
        bindEvent: () => {
          // 离开目标隐藏弹窗
          target.onpointerleave = function () {
            hideTip()
          }
          target.onpointercancel = function () {
            hideTip()
          }
        },
        /**
         * 显示弹窗
         * @param {*} e 事件e
         */
        showTip: (e) => {
          target = e.target // 移入目标
          if (!target) return hideTip()
          // if (targetClass) { // 指定了事件触发目标的 class
          //   target = findNodeFromCurrent(target, targetClass, true)
          // }
          pointerenterHandle(e, 'pointerenter')
          // 目标元素绑定move事件,优化鼠标滚轮和鼠标移入移出事件冲突导致的影响
          if (!addMoveEvented) {
            addMoveEvented = true
            target.addEventListener('pointermove', pointerenterHandle)
          }
        }
      },
      // 点击目标元素触发
      'click': {
        // 初始化
        init: () => {
          refDom.value.onfocus = () => {
            focusIn = true
          }
          refDom.value.onblur = () => {
            focusIn = false
            hideTip()
            emit('clickEnd')
          }
        },
        bindEvent: () => {
          // 具有tabindex属性的标签就可以正常使用onfocus()和onblur()事件了。
          let tabIndex = '-1'
          target.tabIndex = tabIndex
          target.setAttribute('tabIndex', tabIndex);
          target.tabindex = tabIndex
          target.setAttribute('tabindex', tabIndex);
    
          // 先focus才能触发onblur
          target.focus();
          target.style.outline = 'none' // 去掉默认的outline样式,因为目前场景目标元素是用的div
    
          // 失去焦点
          target.onblur = function () {
            // 若点击的弹窗,不关闭
            setTimeout(() => {
              if(!focusIn){
                hideTip()
                emit('clickEnd')
              }
            })
          }
        },
        /**
         * 显示弹窗
         * @param {*} e 事件e
         * @param {*} targetClass 类或id选择器或者元素节点名称,eg: 类:.app | id: #app | 元素节点名称 body - 点击显示弹窗模式下 - 必传,因为若内部有子元素,点击时target是指向的子元素
         */
        showTip: (e, targetClass) => {
          target = e.target // 移入目标
          if (!target) return hideTip()
          if (targetClass) { // 指定了事件触发目标的 class
            target = findNodeFromCurrent(target, targetClass, true)
          }
          pointerenterHandle(e, 'click')
        }
      }
    }
    
    /**
     * 显示弹窗
     * @param {*} e 事件e
     * @param {*} targetClass 类或id选择器或者元素节点名称,eg: 类:.app | id: #app | 元素节点名称 body
     */
    const showTip = (e, targetClass) => {
      eventConfig[props.trigger].showTip(e, targetClass)
    }
    
    // #region 鼠标滚动事件 - 因为鼠标滚轮和鼠标移入移出事件冲突 - 这里在滚动时隐藏弹窗
    const wheelHandle = function (e) {
      hideTip(false)
    }
    const listenMouseWheel = () => {
      if (window.addEventListener) {
        window.addEventListener("wheel", wheelHandle)
      } else {
        window.attachEvent("onmousewheel", wheelHandle)
      }
    }
    const removeMouseWheel = () => {
      if (window.removeEventListener) {
        window.removeEventListener("wheel", wheelHandle)
      } else {
        window.dettachEvent("onmousewheel", wheelHandle)
      }
    }
    // #endregion 鼠标滚动事件
    
    onMounted(() => {
      listenMouseWheel()
      eventConfig[props.trigger].init()
    })
    onUnmounted(() => {
      removeMouseWheel()
    })
    defineExpose({
      showTip, // 鼠标移入或点击显示弹窗
      hideTip
    })
    </script>
    
    <style lang="scss" scoped>
    $fontColor: #333333;
    
    // 鼠标hover详情
    .hoverTip-box {
      position: fixed;
      width: 419px;
      height: 154px;
      background: #FFFFFF;
      color: $fontColor;
      // display: flex;
      justify-content: space-between;
      align-items: center;
      box-shadow: 0px 0px 16px 0px rgba(1, 10, 21, 0.09);
      border-radius: 4px;
      z-index: 7;
      transition: top ease 0.2s;
      // 初始样式
      display: none;
      left: 0;
      top: 0;
    }
    </style>
    

    hoverTip/index.js

    import { ref } from 'vue'
    
    /**
     * js查找指定节点【包含|不包含】往上的节点,可根据类选择器(如:.app)、id选择器(如:#app)、元素节点名称如(h1)进行查找
     * 换句话就是,查找当前节点的指定父节点,可以选择是否是包含当前节点
     * @param ele 子节点
     * @param flag 父节点类或id选择器或者元素节点名称,eg: 类:.app | id: #app | 元素节点名称 body
     * @param includeCurrent 是否包含当前节点,默认false,查找的父节点
     * @returns {HTMLElement | null} 指定的第一个父节点
     */
    export function findNodeFromCurrent(ele, flag, includeCurrent = false) {
      if (!flag || flag === 'body') {
        // 默认body
        flag = 'body'
        return document.getElementsByTagName(flag)[0]
      }
      if (!ele) return null
    
      // 判断是否是这个节点
      let judgeFn = (_node) => {
        if (!_node) return false
        if (flag.startsWith('.')) {
          // 类
          let reg = new RegExp(`^\.`, 'i')
          let classNameStr = flag.replace(reg, '')
          return classNameStr === _node.className || ~_node.className.indexOf(classNameStr)
        } else if (flag.startsWith('#')) {
          // id
          let reg = new RegExp(`^\#`, 'i')
          return flag.replace(reg, '') === _node.id
        } else {
          // 节点名
          return flag === _node.nodeName.toLowerCase()
        }
      }
    
      let parent = null
      if (includeCurrent) {
        // 包含当前节点 - 从当前节点开始
        parent = ele
      } else {
        // 从父节点开始
        parent = ele.parentNode
      }
    
      while (parent && !judgeFn(parent) && parent.nodeName !== 'BODY' && parent.nodeName !== 'HTML') {
        parent = parent.parentNode
      }
    
      return !parent || parent.nodeName === 'BODY' || parent.nodeName === 'HTML' ? null : parent
    }
    
    export default function useHoverTip() {
      const hoverTipDomRef = ref(null)
    
      /**
       * 显示弹窗
       * @param {*} e 事件e
       * @param {*} targetClass 类或id选择器或者元素节点名称,eg: 类:.app | id: #app | 元素节点名称 body
       */
      const hoverTipPointerenter = (e, targetClass) => {
        hoverTipDomRef.value.showTip(e, targetClass)
      }
    
      /**
       * 关闭弹窗,消除副作用
       * @param {*} clear 是否清除其他事件
       */
      const hideTip = (clear = true)=>{
        hoverTipDomRef.value.hideTip(clear)
      }
      return {
        hoverTipDomRef,
        hoverTipPointerenter,
        hideTip
      }
    }
    
    
    

    五、使用方式

    image.png
    image.png

    其实就是要调用hoverTip组件提供的showTip 方法。

    六、顺带说一句

    js动态设置tabIndex后绑定了onblur事件发现第一次未触发,后面想到要先focus才会触发onblur!!!

    若对你有帮助,请点个赞吧,若能打赏不胜感激,谢谢支持!
    本文地址:https://www.jianshu.com/p/68208de9c5c3?v=1694144344201,转载请注明出处,谢谢。

    相关文章

      网友评论

        本文标题:vue3 手写鼠标hover元素显示详情的组件,支持click,

        本文链接:https://www.haomeiwen.com/subject/xrnlvdtx.html