折叠菜单必须采用语义化嵌套结构,以为基础,子菜单嵌套在父内并配aria-haspopup和aria-expanded;禁用height: auto过渡,改用max-height+overflow控制动画;状态须由js精准控制,确保可访问性与交互闭环。

折叠菜单的 DOM 结构必须用语义化嵌套,别堆
纯靠 CSS 控制显隐,但结构松散、无层级关系的菜单,在折叠/展开时极易错位、焦点丢失、屏幕阅读器读不出状态。最稳妥的起点是:<nav></nav> 包 <ul></ul>,每个一级项为 <li>,二级菜单用嵌套 <ul></ul>,并配 aria-haspopup="true" 和 aria-expanded="false"。
常见错误包括:
- 把所有菜单项写成平铺的
<div>,导致键盘 Tab 顺序混乱、<code>:focus样式失效 - 用
<button></button>模拟跳转链接,破坏原生<a></a>的语义和 SEO - 子菜单没包裹在父
<li>内,而是放在外部绝对定位——JS 控制aria-expanded时无法同步更新视觉位置
正确结构示例片段:
<nav aria-label="主菜单"><ul>
<li>
<a href="/user/list">用户管理</a>
<ul aria-hidden="true">
<li><a href="/user/list">列表</a></li>
<li><a href="/user/add">新增</a></li>
</ul>
</li>
</ul></nav>
折叠动画别用 height: auto 过渡,改用 max-height + overflow
height 无法对 auto 做 CSS transition,强行设固定值又难适配动态菜单项高度。实际项目里唯一可靠方案是:用 max-height 控制展开高度,配合 overflow: hidden 和 transition: max-height。
关键细节:
- 闭合态设
max-height: 0,展开态设足够大的值(如max-height: 500px),确保所有子项都能容纳 - 必须加
overflow: hidden,否则内容会溢出遮挡其他区域 - 过渡时间建议
0.2s,太慢显得卡顿,太快则用户感知不到状态变化 - 不要用
display: none切换——它会让键盘焦点直接跳过整个子菜单,违反可访问性要求
折叠状态必须由 JS 控制,且只更新被点击节点
纯 CSS 的 :hover 或 <details></details> 无法记忆状态、不兼容移动端、无法联动路由高亮。必须用 JS 绑定 click 事件,但重点在于「最小化重绘」。
推荐做法:
- 只对触发点击的父级
<li>切换aria-expanded值(true/false) - 只切换对应子
<ul></ul>的aria-hidden和 CSS 类(如.is-open) - 避免调用
innerHTML重写整棵树,尤其当菜单项超过 50 条时,首次渲染延迟明显 - 若菜单数据来自后端 API,折叠状态应与路由解耦——即收起/展开不依赖 URL,而是独立维护在组件内部或 Pinia store 中
折叠后图标按钮必须满足触控安全区,且保留在 DOM 中
移动端小屏下,折叠态菜单常只剩图标,此时按钮热区过小、文字截断、焦点流断裂,是高频崩溃点。
硬性约束:
- 所有折叠控制按钮(含图标)必须设
min-width: 44px和min-height: 44px(iOS 人机指南强制要求) - 文字隐藏时用
visibility: hidden+position: absolute+clip-path: inset(100%),而非display: none - 折叠收起后,焦点不能停在不可见按钮上——需手动
.focus()移到上一个可聚焦元素,或落到<main></main>内首个<h1></h1> - 侧边栏容器自身要设
overflow-y: auto,否则长菜单滚动时会撑破布局
真正容易被忽略的是:折叠态下仍需保持 DOM 结构完整、焦点可到达、屏幕阅读器可播报状态变更——这不是“视觉藏起来就行”,而是整套交互链路的闭环。











