js网页特效入门:制作一个点击展开的下拉菜单

AI资讯4天前发布 jdkjadmin
1 00
js网页特效入门:制作一个点击展开的下拉菜单

js网页特效入门:制作一个点击展开的下拉菜单

下拉菜单看起来只是点击按钮后显示几条链接,实际要处理的事情却不止“显示”和“隐藏”:鼠标点击、键盘操作、焦点位置、屏幕阅读器状态、菜单外部点击、窄屏布局,以及页面中是否有多个菜单,都会影响使用体验。本文从一个简单的导航下拉菜单入手,逐步说明实现方法,并给出可直接运行的示例。

示例只使用原生 HTML、CSS 和 JavaScript,不依赖框架。菜单采用“按钮控制一组普通链接”的模式,适合网站导航、个人账户入口、分类列表等场景。它不是桌面应用里那种需要方向键管理焦点的复杂命令菜单;两者的语义和键盘交互要求不同,不能只因为外观相似就随意套用同一套 ARIA 角色。

一、下拉菜单适合用在哪里

点击展开式菜单适用于内容数量有限、用户需要主动打开才能查看的辅助导航,例如“产品”下面有少量分类、“账户”下面有设置和退出链接,或者工具栏中的操作列表。它可以减少页面常驻内容,在触屏设备上也能明确通过点击打开。

如果菜单项本来就很少,直接显示链接通常更清楚,不必为了视觉效果增加一次点击。如果菜单包含大量层级、复杂筛选,或者用户需要同时比较多项内容,则应考虑专门的导航页面、侧栏或搜索,而不是把所有内容塞进一个小面板。

菜单也不应承担权限控制。把链接藏起来只能改变界面呈现,不能阻止用户访问对应地址。服务器端仍须对每次受保护的请求进行身份验证和权限校验。前端判断可以用来改善体验,但不能作为安全边界。

二、先确定交互和语义

本例用一个原生 <button> 作为开关,用一个普通容器包住链接。按钮的 aria-expanded 表示当前展开状态,aria-controls 指向受控面板的 ID。面板用 HTML 的 hidden 属性隐藏。

这些属性各有用途:aria-expanded 向辅助技术说明按钮控制的内容是否展开;aria-controls 建立按钮与面板之间的关系;hidden 则控制面板是否参与页面呈现。更新状态时要同时更新按钮属性和面板可见性,不能只改颜色或只改其中一个状态。

这里的菜单实际是一个包含导航链接的展开区域,因此链接仍按正常网页链接的方式使用 Tab 键浏览。本例不添加 role="menu" 或 role="menuitem"。这些角色对应更特定的交互模式,通常还需要实现方向键导航、焦点管理等行为;仅添加角色而不实现配套操作,会让辅助技术用户遇到不一致的体验。

三、完整可运行示例

把下面代码保存为 dropdown.html,直接用浏览器打开即可。示例将菜单放在页面上方,提供了基础样式,并实现按钮切换、点击菜单外部关闭、按 Escape 关闭以及关闭后将焦点交还按钮。

<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>点击展开的下拉菜单</title>
  <style>
    * {
      box-sizing: border-box;
    }

    body {
      margin: 0;
      padding: 2rem;
      font-family: system-ui, sans-serif;
      color: #222;
      background: #f6f7f9;
    }

    .site-nav {
      display: flex;
      justify-content: flex-end;
      align-items: center;
      min-height: 3rem;
      max-width: 60rem;
      margin: 0 auto;
    }

    .dropdown {
      position: relative;
    }

    .dropdown__trigger {
      padding: 0.65rem 0.9rem;
      border: 1px solid #777;
      border-radius: 0.35rem;
      color: #222;
      background: #fff;
      font: inherit;
      cursor: pointer;
    }

    .dropdown__trigger:focus-visible,
    .dropdown__panel a:focus-visible {
      outline: 3px solid #2463eb;
      outline-offset: 3px;
    }

    .dropdown__panel {
      position: absolute;
      z-index: 10;
      top: calc(100% + 0.4rem);
      right: 0;
      min-width: 12rem;
      padding: 0.35rem;
      border: 1px solid #d5d8de;
      border-radius: 0.45rem;
      background: #fff;
      box-shadow: 0 0.5rem 1.5rem rgb(0 0 0 / 12%);
    }

    /* 确保 hidden 状态不会被其他 display 样式覆盖。 */
    .dropdown__panel[hidden] {
      display: none !important;
    }

    .dropdown__panel a {
      display: block;
      padding: 0.65rem 0.75rem;
      border-radius: 0.25rem;
      color: #222;
      text-decoration: none;
    }

    .dropdown__panel a:hover {
      background: #eef2ff;
    }
  </style>
</head>
<body>
  <nav class="site-nav" aria-label="主导航">
    <div class="dropdown" id="account-dropdown">
      <button
        class="dropdown__trigger"
        id="account-trigger"
        type="button"
        aria-expanded="false"
        aria-controls="account-panel">
        账户
      </button>

      <div class="dropdown__panel" id="account-panel" hidden>
        <a href="/profile">个人资料</a>
        <a href="/settings">账户设置</a>
        <a href="/help">帮助中心</a>
      </div>
    </div>
  </nav>

  <main>
    <h1>页面内容</h1>
    <p>点击右上方的“账户”按钮,或使用键盘聚焦后按 Enter、空格键,打开菜单。</p>
  </main>

  <script>
    const dropdown = document.querySelector("#account-dropdown");
    const trigger = dropdown.querySelector(".dropdown__trigger");
    const panel = dropdown.querySelector(".dropdown__panel");

    function setOpen(isOpen) {
      trigger.setAttribute("aria-expanded", String(isOpen));
      panel.hidden = !isOpen;
    }

    trigger.addEventListener("click", () => {
      const isOpen = trigger.getAttribute("aria-expanded") === "true";
      setOpen(!isOpen);
    });

    document.addEventListener("click", (event) => {
      if (!dropdown.contains(event.target)) {
        setOpen(false);
      }
    });

    document.addEventListener("keydown", (event) => {
      if (
        event.key === "Escape" &&
        trigger.getAttribute("aria-expanded") === "true"
      ) {
        setOpen(false);
        trigger.focus();
      }
    });
  </script>
</body>
</html>

四、JavaScript 的关键部分

核心逻辑集中在 setOpen 函数中:

function setOpen(isOpen) {
  trigger.setAttribute("aria-expanded", String(isOpen));
  panel.hidden = !isOpen;
}

函数只接受布尔值。打开时,aria-expanded 被设为字符串 "true",面板的 hidden 变为 false;关闭时则相反。DOM 属性值是字符串,所以传给 setAttribute 时要转换。避免在不同事件处理函数里分别修改属性和样式,否则容易出现视觉上已关闭、辅助技术仍报告展开,或反过来的状态不一致。

按钮的点击事件先读取当前状态,再传入反向状态:

trigger.addEventListener("click", () => {
  const isOpen = trigger.getAttribute("aria-expanded") === "true";
  setOpen(!isOpen);
});

原生按钮本身支持键盘激活:获得焦点后,Enter 或空格通常会触发点击事件,所以不必再单独监听这两个按键来切换菜单。若改用 <div> 作为开关,就得自行处理可聚焦性、按钮语义和键盘行为,通常没有必要。优先使用浏览器已经提供这些能力的原生控件。

点击页面其他位置时,本例检查事件目标是否仍在菜单容器内:

document.addEventListener("click", (event) => {
  if (!dropdown.contains(event.target)) {
    setOpen(false);
  }
});

contains 会把按钮和面板中的内容都视为菜单内部。因此点击按钮会由按钮事件切换状态,不会被“点击外部”逻辑误关;点击面板中的链接也不会被当作外部点击。普通导航链接被激活后,浏览器会按链接地址进行导航。

Escape 键用于关闭已展开的菜单,并将焦点放回触发按钮:

document.addEventListener("keydown", (event) => {
  if (
    event.key === "Escape" &&
    trigger.getAttribute("aria-expanded") === "true"
  ) {
    setOpen(false);
    trigger.focus();
  }
});

恢复焦点是重要的小细节。菜单关闭后,键盘用户仍能从刚才的控制按钮继续操作,而不会因为焦点留在已隐藏的链接上而迷失。示例只在菜单展开时响应 Escape,避免无意义地改变其他页面状态。

五、HTML 与 CSS 需要注意什么

type="button" 可以避免按钮在表单内部被误认为提交按钮。如果按钮位于 <form> 中而省略该属性,浏览器默认可能把它当作提交按钮,点击菜单开关就会意外提交表单。

面板采用 position: absolute,因此不会把页面后续内容向下推开;最近的定位祖先 .dropdown 使用了 position: relative,让面板以当前菜单为定位参照。示例用 right: 0 让面板右侧对齐按钮,适合靠页面右侧放置的账户菜单。如果菜单在左侧,可按布局改成 left: 0。

绝对定位并不保证菜单永远完整显示。若菜单靠近视口边缘,面板可能超出屏幕;若祖先元素设置了 overflow: hidden 或 overflow: clip,面板也可能被裁切。需要根据实际页面检查窄屏、缩放和容器裁切情况,必要时调整对齐方向、布局或挂载位置。单纯增大 z-index 不能越过所有裁切和层叠上下文限制。

示例专门写了 .dropdown__panel[hidden] { display: none !important; }。正常情况下,浏览器会根据 hidden 隐藏元素;但作者样式若给同一元素设置了更强的 display 规则,可能覆盖隐藏效果。显式处理这个状态可以减少“面板明明有 hidden 属性却仍显示”的样式冲突。

六、把示例用于真实项目时

多个下拉菜单

示例通过 ID 选取一个菜单,因此只适用于页面上这一组菜单。复制出第二组时,不能复制相同的 ID;ID 必须在同一页面中唯一,按钮的 aria-controls 也必须指向对应面板。要支持多组菜单,应针对每个菜单容器分别查找按钮和面板、分别绑定事件,并明确设计组间行为:打开一个时是否关闭另一个。

还要避免给每个菜单都重复绑定多个全局事件。可以由页面级事件统一处理,或者在组件初始化时记录并管理监听器。若组件会被动态移除,清理监听器也很重要,否则可能造成不必要的引用和重复响应。具体组织方式取决于项目是否使用组件框架;无论采用何种方式,都应确保每个菜单只响应自己的按钮和面板。

点击链接后关闭

本例将面板链接放在普通文档流中。点击链接后通常会离开当前页面,因此无需额外关闭动画。如果链接是站内单页应用路由,页面不一定重新加载,可以在路由跳转或链接激活时关闭面板。不要为了“保险”而给所有链接无条件阻止默认行为,否则会破坏正常导航。

触屏与指针设备

本例不依赖悬停,用户通过点击或触屏都可以打开菜单。避免只用 CSS 的 :hover 显示内容:触屏没有稳定的悬停操作,键盘用户也可能无法触发。触发区域应足够容易点击,菜单项之间要有清晰的间距,并在真实设备上检查页面滚动、视口边缘和触控反馈。

动画与状态

入门版本没有加入展开动画,这是有意的。直接切换 hidden 状态逻辑清晰,也不容易出现动画未结束但交互状态已变化的问题。若确实需要过渡效果,应额外设计进入和退出阶段,并处理动画期间的可访问性、重复点击和隐藏时机;不能简单用透明度隐藏面板,因为透明元素可能仍可聚焦或被辅助技术访问。

七、常见错误与排查方法

  • 按钮点击后页面刷新或表单提交:检查按钮是否位于表单内,并添加 type="button"。
  • 菜单一直显示:检查面板是否带有 hidden,以及 CSS 中是否有 display 规则覆盖了隐藏状态。
  • 菜单能显示,但屏幕阅读器状态不对:确保每次打开和关闭都调用同一个状态函数,同时更新 aria-expanded 和 hidden。
  • 键盘可以打开,却不知道如何返回:实现 Escape 关闭,并在关闭时将焦点移回按钮;同时检查按钮和链接是否保留清晰可见的焦点样式。
  • 点面板内部也被当作外部点击:确认外部判断针对整个菜单容器调用 contains,而不是只检查按钮是否被点击。
  • 出现多个菜单后互相控制:检查选择器作用范围、重复 ID 和事件监听器是否绑定到了正确的元素。
  • 菜单被挡住或截断:检查祖先的 overflow、层叠上下文、定位参照和视口边界,而不是只不断增大 z-index。
  • 使用 innerHTML 拼接菜单项:若内容来自用户输入或外部数据,这可能引入跨站脚本风险。静态链接直接写在 HTML 中即可;需要动态插入纯文本时,优先用 textContent 创建安全的文本节点,并对链接目标进行适当验证。

八、安全与输入处理边界

示例中的菜单内容是写在源代码里的静态文本和固定 URL,不接收用户输入,因此没有理由用 innerHTML 动态拼接。如果改为根据 API 数据生成菜单,应把“显示文本”和“链接地址”分别处理:文本作为文本节点插入;URL 则应按业务要求限制协议和目标,不能因为它来自后端或配置文件就假设一定安全。将不可信字符串拼进 HTML,再交给浏览器解析,可能造成跨站脚本执行。

隐藏面板、移除链接或在 JavaScript 中禁用按钮,都不能保护接口或敏感页面。用户可以查看页面源代码、修改本地脚本或直接请求 URL。真正的访问控制必须由服务器或受信任的服务端 API 执行,并对每个请求验证用户身份与权限。下拉菜单只负责呈现界面,不负责授权。

九、测试清单

发布前可以逐项检查:鼠标点击能否打开和关闭;点击面板外部是否关闭;触屏是否可以正常操作;Tab 能否到达按钮和菜单链接;Enter 或空格能否激活按钮;Escape 是否关闭菜单并恢复焦点;浏览器缩放和窄屏下菜单是否被遮挡;页面是否存在重复 ID;按钮状态与面板可见性是否始终一致。

还应至少用一种屏幕阅读器进行实际检查,而不只是确认属性写在 HTML 上。不同浏览器、辅助技术和页面布局组合可能暴露出单靠代码静态检查发现不了的问题。对于一般导航下拉菜单,保持原生按钮、普通链接、准确的展开状态和可预测的焦点移动,通常比堆叠更多 ARIA 属性更可靠。

总结

点击展开的下拉菜单可以拆成三部分:HTML 提供有明确语义的按钮和链接,CSS 负责定位与视觉呈现,JavaScript 同步控制状态并处理外部点击和 Escape 键。实现时要特别注意原生按钮类型、唯一 ID、aria-expanded 与 hidden 的一致性,以及面板在真实视口中的位置。先用简单、可访问的基础交互,再根据实际需求扩展多菜单、路由关闭或动画效果,通常更容易维护,也更不容易引入边界问题。

© 版权声明

相关文章