popover 与 dialog 实战:不依赖组件库,用原生 HTML 实现菜单与确认框

2026-09-20 0 188

做后台页面的人大概都经历过这个循环:写一个下拉菜单,先加一个 div,再监听 document 的点击来关闭,再处理 z-index 被父级 overflow 裁掉的问题,最后发现键盘 Tab 能跑到菜单外面去。下一期做一个确认弹窗,上面这一套再重来一遍。

用组件库当然能省事,但一个按钮的浮层要拖进来几十 KB 的运行时,总觉得不太划算。好在浏览器这几年把这块的短板补得差不多了:popover、dialog、inert 加上新的 command / commandfor 属性,基本能覆盖大部分浮层场景,代码量小得惊人。下面按「能直接抄去用」的标准,把这套东西过一遍。

先把这块拼图看清楚

这几个特性不是互相替代的关系,各自的分工很明确:

  • popover:轻量浮层,比如下拉菜单、气泡提示、筛选面板。它不阻塞页面其余部分的交互,但支持点击外部关闭。
  • dialog:真正的模态或非模态对话框,模态打开时会把页面其余部分锁住。
  • inert:手动让某块区域「不可交互」,包括鼠标、键盘焦点和辅助技术。
  • command / commandfor:把「点击按钮触发某个动作」这件事从 JS 里搬到 HTML 属性上。

弄清楚这四样各自的边界,后面写代码就很少纠结了。

popover:一个属性换来的浮层

最小可用版本

只要给元素加上 popover 属性,再用 popovertarget 把按钮和它关联起来,一个可以开关、可以按 Esc 关闭、可以点击外部关闭的浮层就成型了:

<button popovertarget="tip">查看说明</button>

<div id="tip" popover>
  <p>这段内容会被渲染到浏览器的顶级层(top layer)上。</p>
</div>

这里一行 JS 都没写。按钮的 popovertarget 默认行为是「切换」,也就是打开状态再点一次会关闭。

三个取值,先记住 auto 就够了

popover 的值有三种:

<!-- 等价于 popover="",带点击外部关闭、Esc 关闭 -->
<div id="a" popover="auto">…</div>

<!-- 只能通过代码或按钮关闭,点外面不管用 -->
<div id="b" popover="manual">…</div>

<!-- 面向 tooltip 一类辅助提示的轻量模式 -->
<div id="c" popover="hint">…</div>

不确定选哪个的时候,先写 auto。真正需要「点别处不能关」的场景,比如一个正在拖拽中的面板,再换成 manual。至于 hint,它主要是给 tooltip 这种一次性提示准备的,自动关闭的规则比 auto 更宽松,普通菜单用不上。

顶级层解决了 z-index 的老问题

这是我个人最喜欢的一点。popover 打开时会被放进浏览器的顶级层,也就是和 document.body 平级的一个独立渲染层。这意味着两件事:

  • 父元素的 overflow: hidden 裁不到它;
  • 你不用再和 z-index: 9999 较劲了。

顺带说一句,浏览器默认样式表给 popover 准备了基础的定位和外观:position: fixedinset: 0margin: auto。所以不写任何 CSS 它也能正常显示——只不过位置是屏幕正中央,而不是贴着触发按钮。

想让它贴着按钮,用锚点定位

要在外部样式表里把浮层对齐到按钮上,可以借助 CSS 锚点定位(以下 CSS 请放在外部样式表或你自己的样式块里):

#menu-btn {
  anchor-name: --menu-btn;
}

#menu {
  position-anchor: --menu-btn;
  top: anchor(bottom);
  left: anchor(left);
  margin: 6px 0 0;
}

需要 JS 介入时

有些场景确实得用代码控制,比如根据接口结果决定是否弹出:

<button id="open-btn">打开面板</button>
<div id="panel" popover="manual">……</div>
const panel = document.getElementById('panel');

document.getElementById('open-btn').addEventListener('click', () => {
  panel.togglePopover();
});

panel.addEventListener('toggle', (event) => {
  console.log(event.oldState, '→', event.newState); // closed → open
});

可选方法有三个:showPopover()hidePopover()togglePopover()。事件则是 beforetoggle(可取消)和 toggle(不可取消)。如果你要在浮层展开前阻止它,用 beforetoggle;只是想知道它开没开,用 toggle 就够了。

dialog:真正的模态,交给浏览器管

popover 不阻塞页面其他区域,而确认框这类场景需要「不处理完就别想干别的」。这就是 <dialog> 的活儿。

showModal 与 show 的区别

<dialog id="confirm">
  <form method="dialog">
    <p>删除后无法恢复,确认继续吗?</p>
    <button value="cancel">取消</button>
    <button value="confirm">删除</button>
  </form>
</dialog>
const dlg = document.getElementById('confirm');

dlg.showModal(); // 模态:进顶级层、加 ::backdrop、页面其余部分自动 inert

dlg.addEventListener('close', () => {
  if (dlg.returnValue === 'confirm') {
    // 执行删除
  }
});

showModal()show() 差在两点:前者会把元素提升到顶级层,并且让页面其余部分自动进入不可交互状态;后者只是把它当作普通块级元素显示出来,用户依然能点页面别的地方。

form method=”dialog” 是个被低估的写法

注意上面那两行按钮:没有 onclick,没有事件监听,按钮的 value 会被写进 dialog.returnValue,同时对话框自动关闭。判断用户点了哪个按钮,只需要读一次 returnValue

更妙的是,这种表单依然会走原生的表单校验。如果对话框里有个必填输入框:

<dialog id="rename">
  <form method="dialog">
    <label>新名称
      <input name="title" required>
    </label>
    <button value="cancel">取消</button>
    <button value="save">保存</button>
  </form>
</dialog>

输入为空时点「保存」,浏览器会弹出校验提示,对话框保持打开,完全不用自己写逻辑。

dialog 还是 popover?

判断标准其实很朴素:用户能不能在浮层打开的时候继续操作页面?能,就是 popover;不能,就是 dialog。下拉菜单、筛选器是前者,删除确认、表单填写是后者。

commandfor:把事件绑定从 JS 里搬走

前面的 popovertarget 只解决 popover 的开关。如果按钮要触发的是对话框,就得靠新的 commandcommandfor 属性组合(早期版本里叫 invoketargetinvokeaction,后来改了名)。

<button commandfor="confirm" command="show-modal">删除</button>

<button commandfor="confirm" command="request-close">取消</button>

内置的命令值有这么几个:

  • show-modal:对 dialog 调用 showModal()
  • close:直接关闭对话框
  • request-close:请求关闭,会先触发 cancel 事件,可以被拦截
  • show-popover / hide-popover / toggle-popover:控制 popover

另外,任何以 -- 开头的自定义值都会被当作自定义命令派发出去,可以用 command 事件接住,适合做「点击按钮触发某个业务动作」的声明式写法。

需要注意的是,command / commandfor 目前主要由 Chromium 系浏览器实现,Safari 和 Firefox 还在跟进。如果项目要兼容这两家,popover 场景继续用 popovertarget(它的支持面已经比较广),dialog 场景则保留一小段 JS 作为兜底。

inert:手动锁住一块区域

模态 dialog 会自动让页面其余部分 inert,你不需要操心。但有些情况浏览器管不到,比如一个用 popover="manual" 实现的侧边抽屉——它不阻塞页面,背后的内容依然可以点。

这时候手动加 inert 就行:

<main id="content">……页面主体……</main>
<div id="drawer" popover="manual">……抽屉……</div>
const drawer = document.getElementById('drawer');
const content = document.getElementById('content');

drawer.addEventListener('toggle', (event) => {
  content.inert = event.newState === 'open';
});

inertaria-hidden="true" 的区别值得说一句:aria-hidden 只是把内容从辅助技术里藏起来,键盘还是能 Tab 进去;inert 是真正的一刀切,鼠标、键盘、屏幕阅读器全都够不着。做浮层遮罩的时候,inert 才是对的那个工具。

完整案例:订单卡片的操作菜单

把上面这些串起来,做一个后台里很常见的场景——订单卡片右上角一个「更多操作」,点开后是重命名和删除,两个动作各自弹对话框。

HTML 部分

<article class="order-card">
  <h2>订单 #20250417-0031</h2>
  <p>状态:待审核 | 金额:¥1,280.00</p>

  <button popovertarget="order-actions">更多操作</button>

  <div id="order-actions" popover="auto">
    <ul>
      <li><button commandfor="dlg-rename" command="show-modal">重命名</button></li>
      <li><button commandfor="dlg-delete" command="show-modal">删除订单</button></li>
      <li><button popovertarget="order-actions" popovertargetaction="hide">关闭</button></li>
    </ul>
  </div>
</article>

<dialog id="dlg-rename">
  <form method="dialog">
    <p>重命名订单</p>
    <label>新名称
      <input name="title" value="订单 #20250417-0031" required>
    </label>
    <button value="cancel">取消</button>
    <button value="save">保存</button>
  </form>
</dialog>

<dialog id="dlg-delete">
  <form method="dialog">
    <p>删除后无法恢复,确认继续吗?</p>
    <button value="cancel">取消</button>
    <button value="delete">确认删除</button>
  </form>
</dialog>

整个交互里,菜单的开关、对话框的弹出、对话框的关闭,全部由属性驱动,一行绑定代码都没有。

JS 部分只管业务

剩下要写的就是「用户到底点了什么」:

const menu = document.getElementById('order-actions');
const renameDialog = document.getElementById('dlg-rename');
const deleteDialog = document.getElementById('dlg-delete');

// 打开对话框前先把菜单收起来,避免浮层叠浮层
document.querySelectorAll('#order-actions button').forEach((btn) => {
  btn.addEventListener('click', () => menu.hidePopover());
});

renameDialog.addEventListener('close', () => {
  if (renameDialog.returnValue !== 'save') return;
  const nextTitle = renameDialog.querySelector('input[name="title"]').value;
  document.querySelector('.order-card h2').textContent = nextTitle;
});

deleteDialog.addEventListener('close', () => {
  if (deleteDialog.returnValue !== 'delete') return;
  document.querySelector('.order-card').remove();
});

读一下 returnValue,该干嘛干嘛,不用再关心焦点管理、Esc 键、背景遮罩这些东西——浏览器已经替你做完了。

几个真会踩到的坑

一、popover 的默认位置在屏幕正中央。很多人第一次写完会发现浮层飞到屏幕中间去了,这不是 bug。浏览器默认样式给了它 inset: 0; margin: auto;。要贴着按钮,老老实实用锚点定位,或者用 JS 读取按钮的 getBoundingClientRect() 手动设位置。

二、两个 auto 类型的 popover 会互相关闭。这是设计如此——打开一个新的,之前那个就自动关掉,正好符合「一次只开一个菜单」的预期。但如果你希望两个浮层能同时存在,得至少把其中一个改成 manual 或者 hint

三、对话框关闭后焦点会回到触发元素上。浏览器默认会这么做,但有个前提:那个触发元素还在 DOM 里。上面的删除案例中,卡片被移除后触发按钮跟着消失了,焦点就会掉到 body 上,键盘用户会瞬间迷失位置。这种场景下需要在 close 事件里手动 focus() 到列表的下一项或者搜索框。

四、commandfor 的支持面还不宽。如果你的用户里有相当比例的 Safari 或 Firefox,把它当作渐进增强来用,保留一段 JS 兜底:

if (!('commandForElement' in HTMLButtonElement.prototype)) {
  document.querySelectorAll('[commandfor][command="show-modal"]').forEach((btn) => {
    btn.addEventListener('click', () => {
      document.getElementById(btn.getAttribute('commandfor')).showModal();
    });
  });
}

什么时候还是该上组件库

原生这套东西覆盖不了所有需求。如果你需要的是:浮层自动避让视口边缘、复杂的嵌套子菜单、拖拽排序、虚拟滚动,或者设计稿要求的动画时序非常精细——那还是老老实实引入成熟方案更省心。

但对于后台系统里最常见的那批交互:下拉菜单、确认框、筛选抽屉、气泡提示,原生的 popover 和 dialog 已经完全够了,而且它们天生带键盘支持和无障碍语义,这一点是自己手搓 div 很难补上的。

下次再写这类组件之前,不妨先问一句:这东西浏览器是不是已经会了?

popover 与 dialog 实战:不依赖组件库,用原生 HTML 实现菜单与确认框
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 html popover 与 dialog 实战:不依赖组件库,用原生 HTML 实现菜单与确认框 https://www.taomawang.com/web/html/2790.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务