JS 特效:制作一个点击展开的折叠菜单

影视资讯4小时前发布 jdkjadmin
0 00
JS 特效:制作一个点击展开的折叠菜单

JS 特效:制作一个点击展开的折叠菜单

折叠菜单把暂时不需要的信息收起,用户点击按钮后再展开。它常见于常见问题列表、设置面板、商品详情和侧边导航。实现时,真正重要的不只是“切换一个显示样式”,还要保证按钮语义、键盘操作、辅助技术可读的状态,以及展开内容与按钮之间的关联。

本文使用原生 HTML、CSS 和 JavaScript 实现一个可直接运行的示例:每个按钮独立控制一个内容面板,点击后展开,再次点击收起。示例不依赖第三方库,也不通过拼接 HTML 字符串生成内容。

一、先明确折叠菜单的行为

开始写代码前,应先确定交互规则。本文采用以下约定:

  • 点击某个按钮,只切换它对应的面板。
  • 多个面板可以同时保持展开;这更接近常见问题列表的行为。
  • 页面初始状态为收起,按钮和面板的状态一致。
  • 按钮使用原生 <button>,因此可通过键盘聚焦,并可用 Enter 或空格触发。
  • 面板收起时使用 HTML 的 hidden 属性,不只是把内容移出屏幕视觉区域。

如果产品要求同一时间只能展开一项,就需要在打开当前面板时同时关闭其他面板。那是另一种交互规则,后文会说明如何调整。

二、可运行的完整示例

把下面代码保存为 accordion.html,然后用浏览器打开即可。代码中的 HTML、CSS 和 JavaScript 都在同一文件中,便于理解;实际项目也可以将样式和脚本拆分到独立文件。

<!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 {
      max-width: 720px;
      margin: 48px auto;
      padding: 0 20px;
      color: #202124;
      font-family: system-ui, sans-serif;
      line-height: 1.6;
    }

    .accordion {
      border-top: 1px solid #d8dce2;
    }

    .accordion__item {
      border-bottom: 1px solid #d8dce2;
    }

    .accordion__trigger {
      display: flex;
      width: 100%;
      align-items: center;
      justify-content: space-between;
      gap: 16px;
      padding: 16px 4px;
      border: 0;
      background: transparent;
      color: inherit;
      font: inherit;
      font-weight: 650;
      text-align: left;
      cursor: pointer;
    }

    .accordion__trigger:hover {
      color: #135caa;
    }

    .accordion__trigger:focus-visible {
      outline: 3px solid #4b8ee8;
      outline-offset: 3px;
      border-radius: 4px;
    }

    .accordion__icon {
      flex: 0 0 auto;
      transition: transform 180ms ease;
    }

    .accordion__trigger[aria-expanded="true"] .accordion__icon {
      transform: rotate(180deg);
    }

    .accordion__panel {
      padding: 0 4px 16px;
      color: #454b54;
    }

    .accordion__panel p {
      margin: 0;
    }

    @media (prefers-reduced-motion: reduce) {
      .accordion__icon {
        transition: none;
      }
    }
  </style>
</head>
<body>
  <h1>常见问题</h1>

  <div class="accordion">
    <section class="accordion__item">
      <h2>
        <button
          class="accordion__trigger"
          type="button"
          id="question-1"
          aria-expanded="false"
          aria-controls="answer-1">
          <span>折叠菜单是什么?</span>
          <span class="accordion__icon" aria-hidden="true">⌄</span>
        </button>
      </h2>
      <div
        class="accordion__panel"
        id="answer-1"
        role="region"
        aria-labelledby="question-1"
        hidden>
        <p>折叠菜单允许用户按需显示或隐藏一段相关内容,常用于常见问题、设置项和详情说明。</p>
      </div>
    </section>

    <section class="accordion__item">
      <h2>
        <button
          class="accordion__trigger"
          type="button"
          id="question-2"
          aria-expanded="false"
          aria-controls="answer-2">
          <span>这个示例需要安装依赖吗?</span>
          <span class="accordion__icon" aria-hidden="true">⌄</span>
        </button>
      </h2>
      <div
        class="accordion__panel"
        id="answer-2"
        role="region"
        aria-labelledby="question-2"
        hidden>
        <p>不需要。示例只使用浏览器内置的 HTML、CSS 和 JavaScript 功能。</p>
      </div>
    </section>

    <section class="accordion__item">
      <h2>
        <button
          class="accordion__trigger"
          type="button"
          id="question-3"
          aria-expanded="false"
          aria-controls="answer-3">
          <span>可以同时展开多个答案吗?</span>
          <span class="accordion__icon" aria-hidden="true">⌄</span>
        </button>
      </h2>
      <div
        class="accordion__panel"
        id="answer-3"
        role="region"
        aria-labelledby="question-3"
        hidden>
        <p>可以。当前示例中,每个按钮只切换与自己关联的面板,不会关闭其他面板。</p>
      </div>
    </section>
  </div>

  <script>
    document.querySelectorAll(".accordion__trigger").forEach((button) => {
      button.addEventListener("click", () => {
        const panelId = button.getAttribute("aria-controls");
        const panel = document.getElementById(panelId);

        // 标记错误的关联,避免继续操作空对象。
        if (!panel) {
          console.warn("找不到折叠面板:", panelId);
          return;
        }

        const willExpand = button.getAttribute("aria-expanded") !== "true";

        button.setAttribute("aria-expanded", String(willExpand));
        panel.hidden = !willExpand;
      });
    });
  </script>
</body>
</html>

三、代码是如何工作的

1. 用按钮表达操作

展开和收起是用户执行的操作,因此这里使用 <button type="button">,而不是用没有语义的 <div> 模拟按钮。原生按钮默认支持键盘焦点和键盘激活,也会向辅助技术表明它是一个可操作控件。

type="button" 也有实际作用:如果这个组件以后被放进表单,未明确设置类型的按钮在部分上下文中可能按提交按钮处理。指定类型可以避免点击菜单时意外提交表单。

2. 用两个属性建立状态关联

每个按钮有一个唯一的 id,并通过 aria-controls 指向对应面板的 id。例如,按钮的 aria-controls="answer-1" 应与面板的 id="answer-1" 完全一致。按钮上的 aria-expanded 则告诉辅助技术该面板当前是否展开,值应为字符串 "true" 或 "false"。

面板使用 aria-labelledby 指向按钮,使面板具有来自按钮文字的可读名称。示例还给面板添加了 role="region",让它作为一个可识别区域呈现。对于只有少量文字的简单列表,不一定要给每个面板都添加 region;页面上区域过多时,反而可能增加辅助技术用户浏览时的负担。是否使用这个角色,应结合内容结构决定。

3. 用 hidden 控制内容可见性

HTML 的 hidden 属性表示元素当前不应呈现。JavaScript 设置 panel.hidden = true 会将面板收起,设置为 false 则会显示面板。代码同时更新 aria-expanded,确保视觉状态与语义状态一致。

不建议只修改按钮文字,或只通过 CSS 改变面板外观而不更新展开状态。用户可能看到内容已展开,但辅助技术仍读到“已收起”;也可能按钮显示“展开”,内容实际上却处于打开状态。

4. 点击时反转当前状态

事件处理器先从按钮读取 aria-controls,再查找相应面板。随后根据 aria-expanded 算出新的状态,并同时更新按钮属性与面板的 hidden 属性。这样状态只有一个明确来源,不必维护另一份容易失同步的变量。

这里使用 getElementById() 查找面板。如果关联目标不存在,方法会返回 null。代码检查了这一情况并提前返回,避免对空对象设置属性时产生运行时错误。实际项目中,如果组件由模板生成,也应保证所有控件和面板的关联完整。

四、如何改成单项展开

常见问题列表通常允许同时展开多个答案,但某些菜单或设置面板会要求一次只打开一项。要实现这种规则,可以在点击处理器中,先关闭同一组内其他已展开的按钮和面板,再打开当前项。重要的是要限定到当前组件,不要无差别关闭页面上其他折叠菜单。

下面展示处理思路,可替换前面脚本中计算状态和更新状态的部分。此片段假设按钮处在同一个 .accordion 容器内:

const root = button.closest(".accordion");

root.querySelectorAll(".accordion__trigger[aria-expanded='true']")
  .forEach((otherButton) => {
    if (otherButton === button) return;

    const otherPanelId = otherButton.getAttribute("aria-controls");
    const otherPanel = document.getElementById(otherPanelId);

    otherButton.setAttribute("aria-expanded", "false");
    if (otherPanel) {
      otherPanel.hidden = true;
    }
  });

const willExpand = button.getAttribute("aria-expanded") !== "true";
button.setAttribute("aria-expanded", String(willExpand));
panel.hidden = !willExpand;

如果产品要求“打开一项后不能通过再次点击收起”,则需要再明确按钮的状态规则,并调整处理器;不能仅凭“手风琴”这个名称假设用户需要哪种行为。不同项目的设计约定可能不同,最好先写成可验证的交互要求。

五、边界与实现选择

默认收起还是默认展开

示例在 HTML 中写了 aria-expanded="false" 和 hidden,代表页面加载时收起。若要默认展开某一项,需要同时把对应按钮改为 aria-expanded="true",并移除该面板上的 hidden。只改其中一处会造成状态不一致。初始化状态最好直接写在 HTML 中,而不是等脚本执行后再猜测页面状态。

JavaScript 未运行时的表现

当前示例的面板初始带有 hidden,而展开能力由 JavaScript 提供。如果脚本因网络错误、浏览器设置或其他原因没有执行,面板会一直收起。对于帮助说明、重要政策或必须可访问的内容,可以选择让内容在无脚本时仍能阅读,再由成功初始化的脚本接管交互;这需要额外设计初始状态和初始化时机,避免脚本未运行与已运行时的呈现不一致。

动画与 hidden

hidden 会直接隐藏面板,不能简单地同时期待它执行高度收缩动画。若必须做高度过渡,需要更复杂的状态管理:展开时先让元素参与布局,再测量内容高度并设置过渡;收起时先动画到零,再在动画结束后隐藏。还要处理窗口尺寸变化、内容动态更新和用户偏好减少动态效果等情况。单纯给 hidden 元素添加 transition 通常不会得到预期效果。若动画不是产品必需,优先保留简单、可靠的显隐逻辑。

示例只旋转装饰性箭头,并通过 prefers-reduced-motion 在用户偏好减少动态效果时关闭旋转过渡。箭头带有 aria-hidden="true",避免把纯装饰字符重复读给屏幕阅读器。

重复使用组件时的 ID

HTML 文档中的 id 应保持唯一。如果页面上复制了多组菜单,却仍然重复使用 question-1、answer-1 等 ID,查找结果可能落到错误元素,aria-controls 也会指向不明确的目标。多实例组件应为每组生成唯一 ID,或在组件容器内使用有作用域的查找方式,并确保 ARIA 引用与实际元素相符。

六、常见错误与排查方法

  • 点击后内容没有变化:检查脚本是否加载、浏览器控制台是否有错误,以及按钮的 aria-controls 是否对应真实面板 ID。
  • 所有按钮都打开同一块内容:通常是多个面板使用了相同 ID,或多个按钮错误地指向同一个面板。逐个核对按钮和面板的对应关系。
  • 收起后仍能通过 Tab 聚焦面板里的链接:检查收起时是否确实给面板设置了 hidden。仅把高度设为零或将文字颜色设为透明,并不会可靠地从键盘顺序和辅助技术呈现中移除内容。
  • 点击按钮导致页面刷新或表单提交:确认按钮写了 type="button",特别是组件位于 <form> 内时。
  • 状态读起来不对:打开开发者工具检查按钮的 aria-expanded 与面板的 hidden 是否匹配;展开时前者应是 "true"、后者应不存在或为 false。
  • 鼠标能用,键盘不好用:优先使用原生按钮,不要移除焦点轮廓;用 Tab 移动到按钮,再用 Enter 和空格分别测试。
  • 动画看起来卡顿:不要同时用 display: none(或 hidden)和高度动画做未经协调的切换。先明确动画需要,再实现状态过渡。

七、安全注意事项

本例切换的是页面中已经写好的面板,没有把用户输入拼接成 HTML,因此没有使用 innerHTML。如果面板内容来自表单输入、接口返回值或其他不可信来源,不要把未经处理的数据直接写入 innerHTML;它可能被浏览器解析为 HTML,并在特定条件下造成跨站脚本攻击(XSS)。只显示纯文本时,应使用 textContent,并根据数据来源和用途进行适当校验。需要渲染允许的富文本时,应使用经过维护的 HTML 清理方案,并按项目的安全策略配置。

折叠菜单本身不是安全边界。把敏感内容设为 hidden 只能改变页面显示方式,不能保护已经发送到浏览器的数据。只要内容随页面源码或接口响应到达用户设备,就不能依靠“收起来”限制用户读取;真正的访问控制必须由服务器端验证。

示例中的 aria-controls、aria-expanded 和 hidden 也不是对参数长度或输入内容的安全限制。它们分别用于关联控件、表达状态和控制呈现。若把外部传入的 ID 用于查找元素,应确保其来自可信的组件结构或进行合适的约束,且不要把它当作授权凭证。

八、发布前检查清单

  • 每个按钮都能准确控制对应面板,ID 不重复。
  • 按钮使用原生语义,类型明确为 button。
  • 初始状态和点击后的状态一致更新,包括 aria-expanded 与 hidden。
  • 键盘用户能够通过 Tab 找到按钮,并用 Enter 或空格切换。
  • 焦点轮廓清晰可见,装饰性图标不会造成无意义的重复朗读。
  • 根据产品需求明确:可否同时展开多项,是否需要默认展开,以及无脚本时如何呈现内容。
  • 没有把折叠隐藏误当作访问控制,也没有将不可信内容直接插入 HTML。

折叠菜单的基础实现并不复杂,但可靠的实现依赖于状态、语义和内容之间保持一致。先用原生按钮、唯一的控件关联和 hidden 完成明确的展开与收起,再按实际需求增加单项展开、动画或动态内容处理,比一开始堆叠复杂逻辑更容易维护。

© 版权声明

相关文章