声明式对话框和弹出式窗口控件

使用 Invoker Commands API,可直接通过 HTML 按钮切换 <dialog> 和 [popover] 元素的可见性,而无需使用自定义 JavaScript 事件监听器。

通过将 commandfor(目标 ID)和 command(操作)属性应用于 <button>,浏览器会自动处理打开/关闭状态更改、焦点管理和无障碍绑定(例如 aria-expanded)。我们建议采用这种声明式方法,因为它消除了脆弱的样板代码,确保在 HTML 解析后立即实现交互功能,并保证了强大、原生可访问的用户体验。

实现声明式弹出式窗口

您可以使用单个按钮切换打开和关闭弹出式窗口。

<!-- MANDATORY: The commandfor attribute links the invoker to the ID of the target element so the browser knows what to control. -->
<!-- MANDATORY: The command attribute specifies the action to perform. Use 'toggle-popover' to handle both open and close states automatically. -->
<button commandfor="my-popover" command="toggle-popover">
  Toggle Popover
</button>

<!-- MANDATORY: The target element must have the popover attribute to be controlled as a popover. -->
<div id="my-popover" popover>
  <p>Popover content goes here.</p>
</div>

如果您需要使用单独的按钮来控制打开和关闭,可以使用 show-popover 和 hide-popover 命令。

<!-- MANDATORY: Use 'show-popover' to explicitly open the popover. It will not close the popover if clicked again. -->
<button commandfor="my-explicit-popover" command="show-popover">
  Show Popover
</button>

<div id="my-explicit-popover" popover="manual">
  <p>This popover is explicitly opened and closed by separate buttons.</p>

  <!-- MANDATORY: Use 'hide-popover' to explicitly close the targeted popover. -->
  <button commandfor="my-explicit-popover" command="hide-popover">
    Hide Popover
  </button>
</div>

实现声明式模态对话框

与弹出式窗口不同,模态对话框通常使用单独的按钮来打开和关闭。仅在需要以模态方式打开对话框时,才使用 show-modal 命令。

<!-- MANDATORY: Use command="show-modal" to trigger the dialog as a modal, trapping focus and preventing interaction with the rest of the page. -->
<!-- MANDATORY: The commandfor attribute connects this button to the dialog ID. -->
<button commandfor="confirm-dialog" command="show-modal">
  Open Confirmation
</button>

<dialog id="confirm-dialog">
  <p>Are you sure you want to proceed?</p>

  <!-- MANDATORY: Use command="close" to dismiss the dialog safely. -->
  <button commandfor="confirm-dialog" command="close">
    Cancel
  </button>
</dialog>

后备策略

由于调用器命令和弹出式窗口尚未得到普遍支持,因此您必须使用 polyfill 作为旧版浏览器的后备方案。

Polyfill Invoker 命令

强制性:通过检查 HTMLButtonElement 原型上的 commandForElement 属性来检测是否支持该功能。请勿检查窗口或文档对象。您必须仅在缺少原生功能时动态导入 polyfill。请勿无条件加载 polyfill。 强制性:直接在目标元素上监听“command”事件,因为原生“command”事件不会冒泡。

选项 1:使用打包器 通过 npm (npm install invokers-polyfill) 安装 polyfill。此方法适用于使用打包器(如 Vite 或 Webpack)或导入映射的项目。对于所有其他设置,请使用下面的 CDN 选项。

// MANDATORY: Feature detect 'commandForElement' on HTMLButtonElement.prototype.
// Conditionally load the invokers-polyfill only in browsers lacking native support.
if (!('commandForElement' in HTMLButtonElement.prototype)) {
  import('invokers-polyfill');
}

选项 2:使用 CDN 对于没有打包器的项目,直接从 <script type="module"> 内的 CDN 动态导入 polyfill。

<script type="module">
  // MANDATORY: Feature detect 'commandForElement' on HTMLButtonElement.prototype.
  // Conditionally load the invokers-polyfill from a CDN only in browsers lacking native support.
  if (!('commandForElement' in HTMLButtonElement.prototype)) {
    import('https://esm.run/invokers-polyfill');
  }
</script>

调用方 Polyfill 限制 强制性:此 Polyfill 不会像原生浏览器那样处理命令按钮的 ARIA 状态(例如 aria-expanded)。强烈建议您自行处理这些状态,以确保您的网站完全无障碍。

如果不支持 Invoker Commands API,则不会触发 command 事件。为了在所有现代浏览器中获得全面支持,建议通过 npm install 或 CDN 使用 https://github.com/keithamus/invokers-polyfill 中的 invokers-polyfill。

此 polyfill 完全支持自定义操作(以 -- 开头),并会像原生 API 一样分派 command 事件。

动态导入(性能优化)

为获得最佳性能,您应仅在浏览器本身不支持该 API 时加载 polyfill。这样可以节省带宽,并缩短现代浏览器用户的脚本执行时间。

注意:此 polyfill 不会管理自定义命令的 ARIA 状态(例如 aria-pressed 或 aria-expanded)。您必须在事件监听器中手动同步这些状态,以确保您的网站可供访问。

// 1. Conditionally load the polyfill
const hasNativeSupport = 'commandForElement' in HTMLButtonElement.prototype;

if (!hasNativeSupport) {
  // Wrap in an async IIFE to avoid top-level await issues in older browsers
  (async () => {
    try {
      await import('https://esm.run/invokers-polyfill');
    } catch (err) {
      console.error('Error loading fallback:', err);
    }
  })();
}

// 2. Manually manage ARIA states in your listener
document.getElementById('action-target').addEventListener('command', (event) => {
  const command = event.command;
  const target = event.target;
  const source = event.source; // The button that triggered the command

  if (command === '--spin') {
    const isSpun = target.classList.toggle('is-spun');
    
    // Polyfill tip: Manually update ARIA to match the new state
    source?.setAttribute('aria-pressed', isSpun);
  }
});

手动回退(传统模式)

如果您不想使用 polyfill,可以结合使用事件委托来分派事件,并使用命令注册表来处理操作。这是传统 JavaScript 开发中的一种常见架构模式,至今仍具有很高的效率和可扩展性。

// 1. **Optional:** Define a registry of requested actions for cleaner logic
const commandRegistry = {
  '--spin': (target) => target.classList.toggle('is-spun'),
  '--grow': (target) => target.classList.toggle('is-grown'),
  '--reset': (target) => target.classList.remove('is-spun', 'is-grown'),
};

// 2. If CommandEvent doesn't exist, we assume no native support and provide the fallback
if (!globalThis.CommandEvent) {
  globalThis.CommandEvent = class CommandEvent extends Event {
    constructor(type, { source, command, ...options } = {}) {
      super(type, options);
      this.source = source;
      this.command = command;
    }
  }
}

// 3. The fallback: Dispatch events manually if native support is missing
  document.addEventListener('click', (event) => {
    const button = event.composedPath().find((el) => el.matches?.("button[commandfor]"));
    if (!button) return;

    const target = document.getElementById(button.getAttribute('commandfor'));
    const command = button.getAttribute('command');

    if (target && command) {
      target.dispatchEvent(new CommandEvent('command', { 
        command, 
        source: button,
      }));
    }
  });

// 4. **Mandatory:** Register the unified listener directly on the target element
document.getElementById('action-target').addEventListener('command', (event) => {
  const command = event.command;
  const target = event.target;
  const action = commandRegistry[command];

  if (action) {
    action(target);
  }
 });

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 的默认定位(如果该用例可以接受)。