4

使用原生 JS 写一个悬浮下拉菜单

 3 years ago
source link: https://paugram.com/coding/js-hover-dropdown-menu.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

悬浮菜单项目.jpg

这是我前段时间写公司项目里面的实践,在此之前我还确实没写过类似的东西,写出这篇文章的目的就是做个详细的编写思路和过程吧!

为了准备这篇文章,我还单独重新写了一次,编写过程还在 B 站全程直播了,然而因为 OBS 的设置问题,导致直播画面显示异常,录像也没有成功留下,最后就只有一个差不多完成了的源码,实属可惜。

用 JS 做交互,首先要确认大概要实现什么样的效果。在这里,我们需要实现一个鼠标移入 Item 之后弹出一个菜单的功能,鼠标离开 Item 和菜单均会让菜单消失。

确认实现方式

这个需求里面,每个 Item 展开的菜单项都是一样的,唯独不同的就是点击之后跳转的页面或是传参。这个功能最简单的实现就是已 Item 作为父元素(position: relative)然后给每一个 Item 下都写一个菜单的 DOM 结构,再使用 position: absolute 定位,根据父元素的 Hover 状态(.site-item:hover > .action-menu)展开菜单。

这样不太好的地方就是 DOM 太多太复杂,而且需要大量的遍历和 onClick 事件。因此我打算尝试一套船新的方案,即菜单的 DOM 只有一个,根据 Item 的鼠标移入,改变菜单的坐标,实现菜单的操作。这样就只需要给一个元素实现点击事件,点击之后干什么,也就只需要改改临时变量就完事了。

这个方案实现的主要难点,其实就是坐标的算法了,除此之外没有什么麻烦的地方了。

先写好 HTML 和 CSS,一个容器里面放着多个站点(Item),鼠标移入每一个站点的时候,展示菜单。

<body>
  <div class="site">
    <div class="site-item">
      <span>保</span>
      <p>保罗的小窝</p>
    </div>
    <div class="site-item">
      <span>保</span>
      <p>保罗 API</p>
    </div>
    <div class="site-item">
      <span>奇</span>
      <p>奇趣起始页</p>
    </div>
    <div class="site-item">
      <span>奇</span>
      <p>奇趣框架</p>
    </div>
    <div class="site-item">
      <span>奇</span>
      <p>奇趣播放器</p>
    </div>
    <div class="site-item">
      <span>方</span>
      <p>方块播放器</p>
    </div>
  </div>

  <div class="float-menu">
    <a>管理此网站</a>
    <a>删除此网站</a>
  </div>
</body>
body {
  padding: 3em;
  font-family: sans-serif;
}

a {
  color: #3498db;
  text-decoration: none;
}

.site {
  display: grid;
  grid-gap: 1em;
  text-align: center;
  grid-template-columns: repeat(auto-fill, minmax(10em, 1fr));
}

.site-item {
  display: block;
}
.site-item span {
  width: 3em;
  height: 3em;
  line-height: 3;
  color: #fff;
  display: inline-block;
  font-size: 3em;
  cursor: pointer;
  background: #ccc;
  border-radius: 6em;
  user-select: none;
}

.float-menu {
  box-shadow: 0 0 1em rgba(0, 0, 0, 0.2);
  position: absolute;
  border-radius: 0.5em;
  padding: 0.5em 0;
  visibility: hidden;
  background: #fff;
  z-index: 1;
}
.float-menu.active {
  visibility: visible;
}

.float-menu a {
  color: inherit;
  display: block;
  cursor: pointer;
  padding: 0.5em 1.5em;
  transition: color 0.3s, background 0.3s;
}
.float-menu a:hover {
  color: #fff;
  background: #3498db;
}

然后就可以开始写 JS 了,先让鼠标移入 Item 的时候能把菜单展示出来:

let menu = document.querySelector(".float-menu");
let items = document.querySelectorAll(".site-item");

function item_enter(){
  menu.classList.add("active"); // 让 menu 显示出来,主要是修改了 visibility 属性
}

items.forEach(item => {
  item.onmouseenter = item_enter;
})

接下来就是把坐标安排上了,我们要拿到 Item 当前在屏幕上的 X 和 Y 坐标,分别是使用 offsetTopoffsetWidth 属性,对应 Item 左上角的位置。我们如果要让横向坐标相对于 Item 居中,那么就得改改算法了,具体计算公式可以参考代码和图片说明。得到坐标之后不要忘记 px 单位喔!

function item_enter(ev){
  menu.classList.add("active"); // 让 menu 显示出来,主要是修改了 visibility 属性

  menu.style.top = ev.target.offsetTop + ev.target.offsetHeight - 25 + "px"; // 纵向坐标
  menu.style.left = ev.target.offsetLeft + ev.target.offsetWidth / 2 - (menu.offsetWidth / 2) + "px"; // 横向坐标
}

悬浮菜单项目-纵坐标.jpg

悬浮菜单项目-横坐标.jpg

制作这个图片还挺费时间的,且看且珍惜啊~

坐标搞定之后,那么就该处理让菜单消失的逻辑了。给 Item 增加 onmouseleave 事件,让菜单离开 Item 就消失。在此之前我用了错误的 onmouseout 事件,这个事件的机制是离开 Item 本身就算是“移出”,鼠标移入 Item 的子元素(span 和 p)都会触发,很明显是不符合我们需求的。

function item_leave(){
  menu.classList.remove("active"); // 让 menu 隐藏,你会发现菜单还没进去就消失了
}

items.forEach(item => {
  item.onmouseenter = item_enter;
  item.onmouseleave = item_leave;
})

写完之后发现,菜单确实可以展示和消失了,但是我鼠标移入菜单,它也会消失,为什么呢?

这其实是因为我们设定的是 Item 的移出事件,我们并不清楚「有没有进入过菜单」的使用情形。所以说得在鼠标移入菜单之前,需要给个很短的时间缓冲(setTimeout),如果确实没移入我们再去隐藏菜单。

设置延迟的位置,主要是在 item_leave 函数里面,因为我们离开了 Item 的时候,有那么一瞬间可能会进入菜单,所以在这里写延迟函数,当然,先把延迟函数本身的存储变量在函数外定义一下。

let timer, menu_entered = false; // 定时器临时变量,还有是否进入菜单的状态记录

function item_leave(){
  timer = setTimeout(() => {
    // 如果没有进入过菜单,才让菜单隐藏
    if(!menu_entered) menu.classList.remove("active");

    // 活干完了,要把定时器取消掉,还原变量
    timer = clearTimeout(timer);
  }, 100);
}

然后进入菜单的时候,把 menu_entered 标记为 true,离开菜单的时候,把定时器干掉(因为下次访问也需要干净的状态)把 menu_entered 还原为 false,并且隐藏菜单(离开菜单是绝对需要隐藏菜单的,如果没进入过菜单,是不可能离开菜单的)

function menu_enter() {
  menu_entered = true;
}

function menu_leave() {
  menu_entered = false; // 还原状态

  timer = clearTimeout(timer); // 清除定时器

  menu.classList.remove("active"); // 隐藏菜单
}

menu.onmouseenter = menu_enter;
menu.onmouseleave = menu_leave;

基本上,这个逻辑就写完了,我们来测试一下程序运行实际情况:

咦,为什么第二次把鼠标移入 Item 的时候,菜单会隐藏呢?

这其实是因为 setTimeout 没有被清除导致的,因为我们只在移出菜单的 menu_leave 里面强制清除了定时器,而定时器本身只到延时结束了才去清除状态,就在这短短的间隙时间里面,我们很有可能会不进入菜单,而切换到其他的 Item,从而出现了上述视频的情况。

解决办法,就是让进入 Item 的时候,强制清除一次定时器,如果它没有被清除的话:

function item_enter(ev){
  if(timer) timer = clearTimeout(timer); // 只要上次的定时器没清除,就清除掉,防止菜单莫名其妙隐藏

  menu.classList.add("active"); // 让 menu 显示出来,主要是修改了 visibility 属性

  menu.style.top = ev.target.offsetTop + ev.target.offsetHeight - 25 + "px"; // 纵向坐标
  menu.style.left = ev.target.offsetLeft + ev.target.offsetWidth / 2 - (menu.offsetWidth / 2) + "px"; // 横向坐标
}

判断条件放上去之后,一切效果都正常了!

菜单项的执行

菜单项目的执行,可以直接先定义一个函数,再去取对应 Item 的 dataset 就可以搞定。

function menu_item_manage(ev){
   // 点击按钮跳转对应页面
  if(url) window.open(url);
}

menu.children[0].onclick = menu_item_manage;

如何确定我当前处于哪个元素呢,你可以在鼠标移入到 Item 的时候(item_enter)存一次对应的状态,离开的时候清除一次。

let timer, menu_entered = false, url;

function item_enter(ev) {
  if (timer) timer = clearTimeout(timer); // 只要上次的定时器没清除,就清除掉,防止菜单莫名其妙隐藏

  url = ev.target.dataset.url;

  ...
}

function item_leave() {
  timer = setTimeout(() => {
    if(!menu_entered){
      menu.classList.remove("active");

      url = undefined; // 离开的时候清除,要放在 menu_entered 里面
    }

    // 活干完了,要把定时器取消掉,还原变量
    timer = clearTimeout(timer);
  }, 100);
}

function menu_leave() {
  ...

  url = undefined;

  ...
}

全篇源码可以从下面的 iframe 处访问:

以上就是本次分享的主要内容,可以看出 setTimeout 在利用 DOM 事件实现页面交互的重要性之高,实现其他类似的方式,这也是一个很好的参考模型,这次的教程你 Get 到了吗?


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK