vue3 手写鼠标hover元素显示详情的组件,支持click,并优化鼠标滚轮与鼠标移入事件冲突导致的交互问题

前言

首先为什么不直接使用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>

    <!-- 关闭按钮 -->
    <el-icon class="hoverTip-icon-close" v-if="showClose" @click="closeHandle"><Close /></el-icon>
  </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
  },
  showClose:{
    type:Boolean,
    default: false
  },
  ignoreClickBlur:{ // 点击事件是否忽略失去焦点事件 - 也就是失去焦点不关闭弹窗,默认关闭
    type: Boolean,
    default: false
  }
})

const emit = defineEmits([
  'close', // 点击关闭按钮
  '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
  }
}

// 关闭
let isClickClose = false // 点击关闭按钮会触发 refDom.value.onblur ???,
const closeHandle = (e)=>{
  isClickClose = true
  hideTip()
  emit('close')
}

// 计算弹窗位置的核心方法
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 = () => {
        if(props.ignoreClickBlur) return // 忽略失去焦点事件

        if(isClickClose) { // 点击关闭按钮会莫名奇妙触发这个失去焦点事件,这里阻止下这个异常情况
          isClickClose = false
          return 
        }
        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 () {
        if(props.ignoreClickBlur) return // 忽略失去焦点事件

        // 若点击的弹窗,不关闭
        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) => {
  isClickClose = false
  eventConfig[props.trigger].showTip(e, targetClass)
}

// #region 鼠标滚动事件 - 因为鼠标滚轮和鼠标移入移出事件冲突 - 这里在滚动时隐藏弹窗
const wheelHandle = function (e) {
  props.trigger === 'click' &&  hideTip(false)
  emit('clickEnd')
}
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 {
  background: #FFFFFF;
  box-shadow: 0px 0px 16px 0px rgba(1, 10, 21, 0.09);
  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: 10;
  transition: top ease 0.2s;
  // 初始样式
  display: none;
  left: 0;
  top: 0;
}
.hoverTip-icon-close{
  position: absolute;
  top: 5px;
  right: 5px;
  font-size: 20px;
  z-index: 9;
  cursor: pointer;
  &:hover{
    color: blue;
  }
}
</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,转载请注明出处,谢谢。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,242评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,769评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,484评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,133评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,007评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,080评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,496评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,190评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,464评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,549评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,330评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,205评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,567评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,889评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,160评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,475评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,650评论 2 335