用原生 Popover 和 dialog 替换手写下拉菜单与弹层组件
给内部后台的表格行加一个"更多操作"菜单,是这个月手头最不起眼的一个小需求:点击行尾的按钮,弹出一个包含"编辑""复制""归档""删除"几项的小菜单。这种组件项目里已经有过好几版实现,我打算这次直接抄现成的写法起步,写着写着才觉得不太对劲。
手写一个下拉菜单,麻烦从哪里开始冒出来
菜单本身用一个绝对定位的 <ul> 就能画出来,麻烦是从"它得叠在别的东西上面"开始的。表格行里还有别的浮层——单元格内的 Tooltip、表头的筛选面板——谁的 z-index 该设多少,设太低会被别的浮层压住,设太高又可能盖住后面弹出的对话框,项目里为此专门维护过一份"层级表",新人接手时基本靠翻文档加试错。
第二个麻烦是点击外部关闭。菜单弹出后,点页面任何别的地方都得让它收起来,通常的做法是在 document 上挂一个 click 监听,判断点击目标是不是在菜单节点内部,不是就关闭。这段逻辑本身不难写,难的是收尾:菜单卸载时要记得摘掉这个全局监听,不摘就是一处隐藏的内存泄漏;而且如果页面里同时开着好几个这种菜单组件,每一个都在 document 上挂监听,事件冒泡的顺序稍微不注意就会出现"点开一个菜单顺带把另一个也触发关闭两次"这类小毛病。
第三个是键盘。Esc 关闭菜单,这个大家都会写,但完整地做到位还包括:菜单弹出后焦点要落到第一项,方向键能在项之间移动,关闭后焦点要还给触发它的按钮,不然键盘用户和屏幕阅读器用户会在原地不知道自己"回到了哪里"。这一套如果每个浮层组件都重新实现一遍,工作量并不小,项目里之前也就只有个别关键组件做得完整,大部分下拉菜单其实是"看起来能用、键盘用户会卡住"的状态。
第四个是定位计算。菜单要贴着触发按钮的下方展开,但按钮可能靠近视口右边缘或底边缘,菜单展开后不能被裁掉,得实时读按钮的 getBoundingClientRect(),算出剩余空间,判断往下展开还是往上翻,往左对齐还是往右对齐。这段逻辑写起来最费神,而且一旦页面里出现横向滚动容器、或者按钮在一个会缩放的祖先元素里,算出来的坐标就可能对不上。
这几件事项目里都不是没做过,只是分散在不同组件里,各自水平参差不齐。这次落笔之前我先去翻了一下今年的浏览器更新日志,想看看有没有省事的办法,结果发现这几件事现在浏览器原生就管了。
popover 属性:不用写全局点击监听
最先看到的是 popover 这个全局属性。给任意元素加上它,这个元素就被浏览器接管进一套叫作 top layer 的渲染层——脱离常规文档流的层叠上下文,永远盖在页面其余内容之上,不用再手动调 z-index。
最简单的用法只需要一个属性、一个 id:
1<button popovertarget="row-menu-42">更多操作</button> 2 3<ul id="row-menu-42" popover> 4 <li><button type="button">编辑</button></li> 5 <li><button type="button">复制</button></li> 6 <li><button type="button">归档</button></li> 7 <li><button type="button" class="danger">删除</button></li> 8</ul>
popovertarget 把按钮和这个弹层关联起来,点击按钮就会切换弹层的显示状态,id 匹配上就行,不用写一行 JS。默认行为是"auto"类型的 popover:打开之后点击弹层外部任意位置会自动关闭,按 Esc 也会关闭,而且同一时刻只允许一个 auto 类型的 popover 处于打开状态——如果这时候页面上还开着别的下拉菜单,点开这一个会自动把前一个收起来,我原来手写的那套"事件冒泡顺序对不上导致两个菜单同时开着"的问题,到这里直接不存在了。
如果想让按钮做"只打开不关闭"或者"只关闭"这种单向操作,可以配合 popovertargetaction:
1<button popovertarget="row-menu-42" popovertargetaction="show">打开菜单</button> 2<button popovertarget="row-menu-42" popovertargetaction="hide">关闭</button>
不写这个属性时默认是 toggle,也就是前面那个按钮的行为:开着就关、关着就开。
样式上,popover 元素默认自带 display: none(未打开时)和一些浏览器给的基础样式,打开状态对应的选择器是 :popover-open:
1[popover] { 2 border: none; 3 border-radius: 8px; 4 padding: 4px; 5 box-shadow: 0 8px 24px rgb(0 0 0 / 0.16); 6} 7 8[popover]:popover-open { 9 display: block; 10}
我把项目里几处现成的下拉菜单换成这个写法后,第一件事就是删掉了那段全局 click 监听和对应的清理逻辑,代码量少了将近一半。
用 CSS 锚点定位贴住触发按钮
省掉点击外部关闭的逻辑之后,剩下最麻烦的定位计算这块,靠的是 CSS Anchor Positioning。
思路是把触发按钮标记成一个"锚点",给它起个名字,弹层元素声明自己相对这个锚点定位:
1button[popovertarget] { 2 anchor-name: --row-menu-anchor; 3} 4 5#row-menu-42 { 6 position: fixed; 7 position-anchor: --row-menu-anchor; 8 position-area: bottom span-left; 9 margin: 4px 0 0; 10}
anchor-name 给按钮起了个自定义标识符(必须以 -- 开头),position-anchor 让弹层绑定到这个锚点,position-area 描述弹层相对锚点摆在哪一片区域——这里是"锚点下方、向左展开对齐"。浏览器会实时读取按钮当前的位置去摆放弹层,滚动、窗口缩放的时候位置自动跟着更新,不用再手写 getBoundingClientRect 和 resize 监听。
真正让我觉得这套东西省心的是靠视口边缘时的自适应。用 position-try-fallbacks 声明一组备选摆放方式,弹层空间不够时浏览器自己挑一个不会被裁切的:
1#row-menu-42 { 2 position: fixed; 3 position-anchor: --row-menu-anchor; 4 position-area: bottom span-left; 5 position-try-fallbacks: flip-block, flip-inline; 6 margin: 4px 0 0; 7}
flip-block 是空间不够时翻到锚点上方展开,flip-inline 是翻到另一侧对齐,浏览器按顺序尝试,选第一个能完整放下弹层的方案。我原来那段"判断往上还是往下展开"的 JS 判断逻辑,整段删掉了。
这里要说清楚一个现实情况:Anchor Positioning 是 Chrome 125(2024 年 5 月)先落地的,Firefox 和 Safari 是后跟上的,具体到我写这篇的时候,两边的稳定版都已经支持核心的 anchor-name/position-anchor/position-area,但 position-try-fallbacks 这类更细的能力在旧一点的版本里还没有。写的时候我加了个 @supports 判断,不支持的浏览器就退回原来那套 JS 定位:
1if (!CSS.supports('anchor-name: --foo')) { 2 // 退回手写定位逻辑 3 positionMenuManually(button, menu); 4}
内部后台的浏览器基本是最新版 Chrome,这个判断在这个项目里其实用不太上,但换成对外的产品我会留着。
顺手把 Tooltip 也换掉
菜单这一处改完,我又想起表格里单元格上那些说明性的 Tooltip,是另一套独立实现——鼠标移上去用 mouseenter 触发,定位逻辑跟菜单几乎是抄来抄去的重复代码。用 popover="hint" 类型加上锚点定位,正好一起处理掉。
hint 类型和默认的 auto 类型区别在于:多个 hint 类型的 popover 之间不会互相顶掉对方,也不会打断当前正打开的 auto 类型 popover——这意味着表格行的操作菜单开着的时候,鼠标划过某个单元格触发的说明提示照样能弹出来,不会把菜单意外关掉,这正是我原来那套实现里没考虑到、后来靠打补丁修的一个交互细节。
1<td> 2 <span class="cell-value" id="status-cell-42">异常</span> 3 <div popover="hint" id="status-tip-42" anchor-name-target="status-cell-42"> 4 最近一次心跳超时超过 30 秒,系统自动标记为异常 5 </div> 6</td>
1#status-cell-42 { 2 anchor-name: --status-42; 3} 4 5#status-tip-42 { 6 position: fixed; 7 position-anchor: --status-42; 8 position-area: top; 9 margin-bottom: 6px; 10 font-size: 13px; 11 padding: 6px 10px; 12 border-radius: 6px; 13 background: #1f2328; 14 color: #fff; 15}
hint 类型的 popover 不会像按钮那样自带点击触发,得自己写显隐时机,鼠标悬停和聚焦都要覆盖到,聚焦是给键盘用户看的:
1const cell = document.getElementById('status-cell-42'); 2const tip = document.getElementById('status-tip-42'); 3 4['mouseenter', 'focus'].forEach((evt) => { 5 cell.addEventListener(evt, () => tip.showPopover()); 6}); 7['mouseleave', 'blur'].forEach((evt) => { 8 cell.addEventListener(evt, () => tip.hidePopover()); 9});
showPopover() 和 hidePopover() 是 popover 元素自带的方法,不需要 popovertarget 也能用 JS 直接调,toggle 一半是声明式、一半是命令式这种混用方式在项目里挺常见——按钮点击用声明式的 popovertarget 省一行 JS,鼠标悬停这种没有天然按钮语义的场景用命令式的方法调用。
toggle 事件:懒渲染菜单内容
操作菜单里"归档"这一项要先查一次这条记录当前是否已经在归档流程中,查完才决定这一项要不要显示成禁用状态。这个查询没必要在页面加载时就做,只在菜单真正要打开的那一刻查最划算。
popover 元素有个 beforetoggle 事件,在状态即将切换前触发,事件对象带着 oldState 和 newState,正好用来做这种懒加载:
1const menu = document.getElementById('row-menu-42'); 2 3menu.addEventListener('beforetoggle', (e) => { 4 if (e.newState === 'open') { 5 checkArchiveStatus(currentRowId).then((archived) => { 6 menu.querySelector('.archive-item').disabled = archived; 7 }); 8 } 9});
toggle 事件则是状态已经切换完成之后触发,我拿它做过一次埋点,统计这个操作菜单实际的点开次数,跟当初手写版本里散落在各处的埋点调用比,现在只需要在一个地方监听就够了。
无障碍这块,是原生方案白送的部分
前面提到的 Esc 关闭、点击外部关闭,popover 都替我做了,这部分只是省代码。真正让我觉得值得的是它在无障碍上补的东西。
auto 类型的 popover 有一套叫 light dismiss 的行为:点击弹层之外任意区域会关闭它,这个行为浏览器统一处理,不需要我判断"这次点击到底算不算点在外面"。同时浏览器会自动给触发按钮和弹层元素之间建立 ARIA 关联——只要用了 popovertarget,屏幕阅读器就能识别出这个按钮控制着哪个弹层,之前如果要手写这块,得自己在按钮上加 aria-haspopup、aria-expanded、aria-controls,状态切换时还要记得同步更新 aria-expanded 的值,很容易漏掉。
菜单项本身是普通的 <button>,Tab 键顺序和方向键导航是浏览器原生表单控件自带的,不需要额外写方向键切换焦点的逻辑。焦点回归也是免费的:关闭 popover 后,焦点会自动回到触发它的按钮上,不用自己存一份"打开前的焦点元素"再手动 .focus() 回去。
这几项加起来,等于把一个原来需要专门写测试用例覆盖的无障碍清单,变成了默认行为,我只需要检查它有没有按预期工作,而不是从零实现。
需要真正挡住交互的场景,用 dialog
菜单这类轻量的东西用 popover 很合适,但项目里还有个"删除确认"弹窗,逻辑不一样:用户点了删除,必须先在这个提示框里做出选择,背后的页面不能继续操作。这种场景 popover 就不太合适了,因为它默认不会阻塞背后的交互——点击外部会关闭它,但关闭前用户理论上还是能通过键盘导航之类的方式碰到背后的内容。真正要"锁住"页面的场景,得用 <dialog> 元素。
<dialog> 元素本身存在很多年了,但过去大多数项目没拿它当模态框用,主要是因为它默认打开的方式 .show() 不会挡住背后的交互,跟直接摆一个 div 没什么区别。真正有用的是 .showModal():
1<dialog id="confirm-delete"> 2 <form method="dialog"> 3 <p>确定要删除这条记录吗?此操作无法撤销。</p> 4 <menu> 5 <button value="cancel">取消</button> 6 <button value="confirm" class="danger">删除</button> 7 </menu> 8 </form> 9</dialog>
1const dialog = document.getElementById('confirm-delete'); 2 3document.querySelectorAll('.row-delete-btn').forEach((btn) => { 4 btn.addEventListener('click', () => dialog.showModal()); 5}); 6 7dialog.addEventListener('close', () => { 8 if (dialog.returnValue === 'confirm') { 9 deleteRow(currentRowId); 10 } 11});
showModal() 打开的对话框会把它也放进 top layer,同时把背后的整个页面标记为 inert——键盘 Tab 走不进去,鼠标点击也不会触发背后元素的事件,直到对话框关闭为止。这正是我要的"锁住"效果,而且这个锁定是浏览器实现的,不是我拿一个全屏遮罩 div 加 pointer-events: none 硬凑出来的。
method="dialog" 的表单提交行为也挺方便,点了哪个按钮,它的 value 就会变成 dialog.returnValue,对话框自动关闭,不用自己写 preventDefault 再手动调 .close()。
背景遮罩用 ::backdrop 伪元素直接选中,不用额外套一层遮罩节点:
1#confirm-delete::backdrop { 2 background: rgb(0 0 0 / 0.4); 3 backdrop-filter: blur(1px); 4} 5 6#confirm-delete { 7 border: none; 8 border-radius: 10px; 9 padding: 24px; 10 max-width: 360px; 11}
焦点管理这块 <dialog> 也是原生做的:打开时焦点会移到对话框内第一个可聚焦元素上,Tab 键会被限制在对话框内部循环,出不去,这就是通常说的焦点陷阱,不用自己监听 keydown 判断是不是绕出了容器边界。Esc 键默认会触发取消并关闭对话框,returnValue 会是空字符串,我在 close 事件里判断这种情况就当作取消处理。
ARIA 语义上,<dialog> 通过 showModal() 打开后,浏览器会赋予它 role="dialog" 的隐式语义,配合 aria-labelledby 指向标题元素就能让屏幕阅读器正确播报,比自己在一个 div 上手动拼一套 ARIA 属性要省心。
popover 和 dialog 怎么选
菜单换完之后,我把项目里其他几处浮层也过了一遍,判断标准慢慢清楚了:轻量的、不阻塞背后操作的提示类内容——下拉菜单、Tooltip、筛选面板、通知条——用 popover;真正需要让用户先处理完当前弹层、背后暂时不能操作的——确认框、表单弹窗、图片查看器这类——用 dialog 的 showModal()。
两者也不是完全互斥。<dialog> 本身也可以不调用 showModal()、只用普通的 .show() 或者干脆加 open 属性,这样它表现得跟 popover 差不多,只是没有 top layer 和背景遮罩。反过来 popover 也有个 popover="manual" 的变体,关掉自动的 light-dismiss 行为,改成完全由 JS 控制开关,适合那种"点击外部不应该关闭"的场景,比如一个带搜索框的下拉列表,用户点进搜索框继续输入不该被当成点了外部。
Tooltip 这种最轻的提示,我倾向于连 popovertarget 都不用,直接用 popover="hint"——这是专门为提示类内容准备的类型,多个 hint 类型的 popover 之间不会互相挤掉彼此,也不会打断 auto 类型 popover 的状态,跟一个正打开的下拉菜单同屏出现互不干扰。
兼容和渐进增强的现实考虑
popover 属性和 <dialog> 的 showModal() 目前主流浏览器的稳定版都已经支持,是可以放心用在生产环境的一对特性。CSS Anchor Positioning 相对新一些,落地时间也参差——支持得早的浏览器已经用了两年,跟进晚一些的浏览器版本也补上了核心能力,但一些细分特性(比如多重回退策略、锚点作用域)在旧版本里还缺。我这次的处理方式是:核心的开关行为(popover、popovertarget、showModal)直接用,不加任何检测,因为不支持的浏览器会把这些属性当普通属性忽略,最多是弹层变成一段普通 DOM 一直显示或者一直隐藏,不会报错崩溃;定位这块用 @supports 判断,退回手写逻辑保底。
内部工具类项目浏览器环境统一,这块判断基本用不上,但如果换成面向公开用户的产品,我会把退回逻辑写完整,而不是赌用户都在用新版浏览器。
加一点进场退场动画
菜单出现得太突兀,我想给它补一个从锚点方向轻微滑入的效果。popover 打开关闭是直接在 display: none 和渲染之间切换,中间没有一个"正在过渡"的中间态可以补动画,这也是过去很多人放弃用它做带动画弹层的原因。
现在有 @starting-style 规则可以补上这段。它声明的是元素刚从 display: none 切到可见那一瞬间的初始样式,浏览器会在这个初始样式和目标样式之间做过渡:
1[popover] { 2 opacity: 0; 3 transform: translateY(-6px) scale(0.98); 4 transition: opacity 0.16s ease, transform 0.16s ease, display 0.16s allow-discrete; 5} 6 7[popover]:popover-open { 8 opacity: 1; 9 transform: translateY(0) scale(1); 10} 11 12@starting-style { 13 [popover]:popover-open { 14 opacity: 0; 15 transform: translateY(-6px) scale(0.98); 16 } 17}
transition-behavior: allow-discrete(这里写成 transition 简写里的 display 0.16s allow-discrete)是让 display 这种本身不能过渡的离散属性也能参与过渡时序:关闭时先把透明度动画播完,display 才真正切回 none,不然默认情况下元素会瞬间消失,动画根本来不及看见。
这一步做完,简单的淡入加位移已经够用了。但如果要做更复杂的效果——按方向交错入场、跟随手势的拖拽退出——写起来就要在这几个属性之间来回调试时序,还要应付不同浏览器对 @starting-style 支持程度的差异,这时候我会倾向于承认现成动画库确实比手工调这套时序更省时间。
什么场景我还是会用现成的组件库
这几处换完之后不代表以后所有浮层都不用第三方库了。有几类场景我还是会继续用 Radix 或者类似的库。
一个是更复杂的进场退场动画,比如上面这种简单淡入位移之外,还要交错动画、路径动画这类效果,写起来比在一个受控组件里用现成的动画库麻烦不少。
一个是深层嵌套的浮层结构,比如一个下拉菜单里又套了一个二级子菜单,子菜单还要跟着父菜单联动定位和联动关闭。原生 popover 的 auto 类型之间有一套"祖先弹层"的关联规则可以处理这种嵌套,但规则本身有一些细节(比如子菜单要在 DOM 结构里明确是父菜单的后代,或者用 popovertarget 反向声明关联),实现起来要花时间摸清楚,遇到多级联动、拖拽排序这类更复杂的交互,现成库里已经把这些边角情况踩过一遍,直接用能省不少调试时间。
还有一类是跨浏览器细节差异带来的样式坑,比如 <dialog> 的 ::backdrop 在不同浏览器里默认的层叠行为、动画支持程度不完全一致,一个成熟的组件库通常已经把这些差异抹平了,自己从零对齐反而更费工夫。
这次的表格操作菜单和删除确认框都是相对独立、不嵌套的场景,原生方案完全够用,换完之后代码量降了不少,也不用再单独维护一份层级表和一套点击外部关闭的公共逻辑。后面如果要做更复杂的联动菜单,我大概率还是会评估要不要上一个专门的库,但至少这一批简单浮层,不用再重复造轮子了。