语义正确、可访问的纵向折叠菜单需严格遵循HTML父子嵌套结构,子菜单<ul>必须直接嵌套在触发它的<li>内;CSS避免破坏列表流和聚焦逻辑;JS应使用事件委托并确保键盘与读屏支持。

直接用 <ul> 嵌套 <li> 就能做出语义正确、可访问性好、浏览器原生支持键盘导航的纵向折叠菜单——但多数人栽在结构错位、CSS 选择器误配、或 JS 事件绑定方式上。
HTML 结构必须严格遵循父子嵌套顺序
子菜单 <ul> 必须紧跟在触发它的 <li> 内部,不能用 <div> 包裹,也不能放到兄弟节点里。错误结构会导致屏幕阅读器跳过子项、Tab 键无法进入子菜单、CSS :has() 失效。
- ✅ 正确:
<li>菜单项<ul class="submenu"><li>子项</li></ul></li> - ❌ 错误:
<li>菜单项</li><ul class="submenu"><li>子项</li></ul>(子菜单脱离父<li>) - ❌ 错误:
<li><div>菜单项</div><ul class="submenu">...</ul></li>(<div>破坏列表语义) - 建议给每层子菜单加统一 class,比如
class="nested",避免用ul ul ul这类宽泛选择器污染样式
CSS 控制显示/隐藏时别用 display: flex 破坏列表流
顶层 <ul> 如果设了 display: flex 或 display: grid,会切断浏览器对列表项顺序和缩进的默认处理,导致 Tab 键焦点跳跃异常、list-style 失效、打印样式错乱。
- 纵向菜单保持
display: block(默认值),用margin或padding控制间距 - 子菜单默认隐藏:用
.nested { display: none; },不要写成visibility: hidden(仍占空间)或opacity: 0(无法键盘聚焦) - 展开时推荐加过渡:用
max-height+overflow: hidden替代纯display切换,实现平滑收放动画 - 图标状态同步:用
.menu-item.active > .toggle::before { content: "▼"; }这类精准子选择器,避免后代选择器误改其他层级
JavaScript 绑定 click 事件优先用事件委托
直接给每个 <li> 绑 onclick 属性或循环 addEventListener,在动态增删菜单时容易漏绑、重复绑、内存泄漏。事件委托一次注册,一劳永逸。
立即学习“前端免费学习笔记(深入)”;
- 监听顶层
<ul>的click,用e.target.matches('.menu-item > .toggle')判断是否点中展开按钮 - 切换逻辑只操作最近的
.nested:用e.target.closest('.menu-item').querySelector('.nested') - 避免用
innerHTML重绘整个菜单树——会丢失焦点、清空表单输入、触发多次重排 - 如果菜单含链接(
<a>),记得在e.preventDefault()前判断是否点击的是展开按钮,否则禁用跳转
键盘交互和可访问性不是加分项,是硬性要求
仅靠鼠标点击展开的菜单,在 WCAG 2.1 和国内《信息技术 互联网内容无障碍可访问性指南》下属于不合格。用户按 Tab 键进入菜单项后,必须能用空格/回车展开,用方向键上下移动,用 Esc 收起当前展开项。
-
<li>要有tabindex="0"才能被键盘聚焦(<a>默认可聚焦,但<span>不行) - 展开按钮需带
role="button"和aria-expanded="false",JS 切换时同步更新该属性 - 子菜单容器加
role="group",并用aria-labelledby关联父项 ID - 不要依赖
:hover实现展开——触屏设备无 hover,且对键盘用户无效
真正难的不是让菜单动起来,而是让它在盲人用读屏软件、工程师用键盘、手机用户单手操作时,都表现一致。结构松散、CSS 滥用、JS 直接操作 DOM,三者任一出问题,折叠菜单就从便利功能变成可访问性障碍。



















