对话框

概览

对模态和非模态对话框使用原生 <dialog> 元素。它提供了无障碍语义、焦点处理、顶层渲染和 Esc 处理,而这些都是自定义叠加层必须手动重建的功能。

选择叠加层类型

选择第一个匹配的规则。如果不确定,请使用规则 1。

规则 1:叠加层会干扰用户并遮挡页面(默认)

用于确认、表单、登录、破坏性操作以及任何未另行说明的“模态”“对话框”“灯箱”或“弹出式窗口”请求。

使用 <dialog> 以模态形式打开,以声明方式使用 command="show-modal"(请参阅声明式对话框和弹出式窗口控件),或通过脚本使用 showModal()。

<button type="button" command="show-modal" commandfor="confirm">Delete</button>

<!-- MANDATORY: no role="dialog" or aria-modal; the native modal already provides them -->
<dialog id="confirm" aria-labelledby="confirm-title">
  <h2 id="confirm-title">Delete this item?</h2>
  <form method="dialog">
    <button value="cancel" autofocus>Cancel</button>
    <button value="delete">Delete</button>
  </form>
</dialog>

浏览器提供:惰性页面、焦点限制、顶层、::backdrop、Esc 和返回手势关闭,以及焦点返回到调用方。

  • 请勿添加 role="dialog"、aria-modal、焦点陷阱、inert 切换或 Esc 处理程序。
  • 请勿使用 show() 或 open 属性打开该文件。
  • 务必使用以下 CSS 停止页面在对话框后面滚动。如需了解详情,请参阅防止模态对话框下方的页面滚动。
  • 请勿在 html 或 body 上使用 overflow: hidden 或 position: fixed 锁定滚动。
/* MANDATORY (both rules): without them, scrolling over the backdrop or inside the dialog scrolls the page behind it */
dialog:modal {
  overscroll-behavior: contain; /* stops scroll gestures in the dialog chaining to the page */
}
dialog::backdrop {
  overflow: hidden; /* ::backdrop is not a scroll container by default, so it would ignore overscroll-behavior */
  overscroll-behavior: contain;
}

不适用于菜单或提示:请参阅规则 3。

规则 2:用户在面板打开时继续在页面中工作

用于查找栏、检查器、调色板和撰写窗格,这些在语义上仍属于对话框。

使用 <dialog popover> 可创建具有弹出式窗口关闭功能的顶层面板(auto 会轻触关闭,manual 不会)。仅在面板不得位于顶层时使用 dialog.show()。

<button type="button" popovertarget="find">Find</button>
<dialog id="find" popover="manual" aria-labelledby="find-title">
  <h2 id="find-title">Find</h2>
  <input type="search" aria-label="Search">
  <button type="button" popovertarget="find" popovertargetaction="hide">Close</button>
</dialog>

该页面仍可互动,不会变为非活跃状态。

  • 务必添加可见的关闭按钮。
  • 请勿使用 showModal(),这会屏蔽网页。

不适用于屏蔽确认:请参阅规则 1。

规则 3:叠加层不是对话框

用于菜单、下拉菜单、提示、悬停卡片和消息框。

使用具有 popover 属性(而非 <dialog>)的语义元素。

<button type="button" popovertarget="menu">Actions</button>
<ul id="menu" popover>
  <li><a href="/rename">Rename</a></li>
</ul>

选择如何关闭

默认值取决于对话框的打开方式。如果 closedby 缺失或无效,showModal() 的行为将与 closerequest 相同,而任何其他打开方式的行为将与 none 相同。

  • 仅限 Esc 键和返回手势(模态默认值):不设置任何内容,或设置为 closedby="closerequest"。
  • 点击外部区域也可关闭:使用 closedby="any"。
  • 仅由开发者关闭(例如,包含未保存更改的表单):使用 closedby="none" 并添加确认路径。请参阅下文的“结语”。

指南

无障碍名称和初始焦点

  • 务必为每个对话框命名,使用 aria-labelledby 指向其标题,或者在没有可见标题时使用 aria-label。
  • 务必将 autofocus 放在主要控件或第一个字段上。
  • 务必在破坏性确认中将 autofocus 放在安全操作(例如“取消”或“保留”)上。
  • 请勿编写代码来在关闭时返回焦点。浏览器会将其返回给调用方。

开场白

结束语

  • 务必使用 <form method="dialog"> 按钮或 command="close" 关闭。
  • 务必谨慎选择 closedby:any(轻触关闭和 Esc)、closerequest(仅限 Esc,模态默认值)或 none(仅限开发者控制的关闭)。
  • 务必对必须遵循“未保存更改”逻辑的“取消”按钮使用 command="request-close" 或 requestClose()。它们会先触发 cancel,并忽略 closedby。
  • 务必将 closedby 视为渐进增强。Esc和移动设备返回手势已经可以在没有该按钮的情况下关闭模态窗口。

挂接到关闭

  • 务必在 close 监听器中读取 dialog.returnValue,以了解使用了哪个按钮,然后使用 dialog.returnValue = '' 将其重置。Esc 和其他关闭请求不会设置任何值,并且浏览器在是否清除先前值方面存在差异,因此如果不进行重置,过时的值可能会泄露。将 '' 视为“未选择即关闭”。
  • 务必使用 cancel 拦截 Esc 和 request-close,但不要在没有用户激活的情况下依赖 preventDefault() 来处理重复的 Esc 按压。
  • 务必将 closedby="none" 与明确的确认步骤结合使用,以处理脏表单。
  • 务必在 cancel 被阻止时告知用户对话框保持打开状态的原因:显示一条可见消息,并使用 aria-describedby 从获得焦点的控件(例如“舍弃”)引用该消息,以便屏幕阅读器在焦点移动时朗读该消息。如果没有该属性,当焦点移至新按钮时,系统只会播报该按钮的名称。
  • 请勿让确认操作将控件移到指针下方。保留消息的空间(使用 visibility 隐藏,而不是 display 或 hidden),并在现有按钮不会移动的位置(例如,右对齐行的第一个位置)添加破坏性按钮,这样在同一位置再次点击就不会舍弃用户的更改。

非模态对话框

  • 务必对顶层面板使用 <dialog popover>(auto 或 manual),在不需要顶层时使用 dialog.show()。
  • 务必在每个非模态对话框中包含可见的关闭按钮。

背景幕和样式

无障碍

  • 请勿向原生模态窗口添加 role="dialog"、aria-modal、手动焦点陷阱或 inert 切换。
  • 务必使用具有无障碍名称的原生 <button> 作为关闭控件。
  • 务必为对话框标题使用一致的标题级别。

后备策略

closedby (dialog-closedby)

即使没有 closedby,模态窗口仍可通过 Esc 和返回手势关闭;只是失去了点击外部区域关闭的功能,因此这属于优雅降级。closedby="none" 会以同样的方式被忽略:Esc 会触发 cancel,然后关闭对话框。将未保存更改防护机制保留在 cancel 处理程序中,而不是仅保留在 closedby="none" 中,这样它也可以在缺少 closedby 的情况下提供保护。如果您的基准目标需要轻触关闭,请在缺少 closedby 的位置添加以下代码:

if (!('closedBy' in HTMLDialogElement.prototype)) {
  // Test the pointer position, not event.target: the <dialog> is also the target for its own
  // padding and the gaps between its children, so a target check would close on those clicks
  const outside = (e, dialog) => {
    const r = dialog.getBoundingClientRect();
    return e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom;
  };
  for (const dialog of document.querySelectorAll('dialog[closedby="any"]')) {
    let pressedOutside = false;
    dialog.addEventListener('pointerdown', (e) => { pressedOutside = outside(e, dialog); });
    // Require the press and the release outside, so a text-selection drag that ends on the backdrop does not dismiss
    dialog.addEventListener('pointerup', (e) => { if (pressedOutside && outside(e, dialog)) dialog.close(); });
  }
}

overscroll-behavior

将滚动包含视为渐进增强。请勿进行特征检测:即使不可滚动容器会忽略该属性,@supports 也会报告该属性受支持。在未强制执行该属性的情况下,只要对话框自身的内容溢出,对话框仍会包含滚动条,并且 showModal() 仍会使页面处于非活动状态。请勿在 html 或 body 上回退到 overflow: hidden 或 position: fixed,这会移除根滚动条并改变布局。

Invoker 命令(command/commandfor)

如果您的基准目标不包含调用器命令,请保留相同的标记并有条件地加载 polyfill(与渐进增强相同的体验)。在接口上检测,绝不无条件加载:

// MANDATORY: feature detect on HTMLButtonElement.prototype and load only when native support is missing
if (!('commandForElement' in HTMLButtonElement.prototype)) {
  import('invokers-polyfill'); // npm install invokers-polyfill
}

只有规则 2 和 3 使用了 Popover。

Popover 的回退和浏览器支持

Popover API 主要是一种渐进增强技术,但其核心行为(顶层提升、轻触关闭和 popovertarget 调用)没有仅限 CSS 的等效项。旧版浏览器需要 polyfill,如果您不想提供 polyfill,则需要手动后备方案。

Polyfill:如需在旧版浏览器中支持 popover 属性,请有条件地加载 @oddbird/popover-polyfill。强制性要求:通过检查 HTMLElement.prototype 上的 popover 属性来检测功能,并且仅在缺少原生支持时加载 polyfill,请勿无条件加载。

使用打包工具或导入映射:

// MANDATORY: Feature detect 'popover' on HTMLElement.prototype.
if (!("popover" in HTMLElement.prototype)) {
  import("@oddbird/popover-polyfill");
}

如果没有打包器,请从 <script type="module"> 内的 CDN 导入:

<script type="module">
  if (!("popover" in HTMLElement.prototype)) {
    import("https://unpkg.com/@oddbird/popover-polyfill@latest/dist/popover.min.js");
  }
</script>

样式注意事项:polyfill 无法定义真正的 :popover-open 伪类,因此它会改为应用 .\:popover-open 类。强制性:使用 :is() 或 :where() 将两者结合,否则缺少 :popover-open 的浏览器会舍弃整个规则:

[popover]:is(:popover-open, .\:popover-open) {
  display: block;
}

或者,对于没有 polyfill 的旧版后备方案,请使用 position: fixed 并通过 getBoundingClientRect() 手动计算坐标,或依赖 inset: auto 的默认定位(如果该用例可以接受)。