自定义按钮操作

Web 功能: 调用器命令

借助 Invoker Commands API,按钮可以使用 HTML 属性以声明方式触发目标元素上的操作。 这种方法减少了对手动事件监听器的需求,并将界面与实现细节分离开来。 对于应用专属的自定义操作,您可以自行定义命令名称。自定义命令必须以双短划线 (--) 为前缀,以免与未来的内置浏览器命令发生冲突。

实现步骤

  1. 定义目标元素:确定将响应操作的元素。如果该商品没有唯一的 id,请添加一个。
  2. 配置调用方按钮:使用 commandfor 属性指向目标的 id,并使用 command 属性指定自定义命令名称(以 -- 为前缀)。
  3. 处理命令事件:直接在目标元素上附加 command 事件监听器。事件对象包含 command 属性和 target 属性(引用由 commandfor 标识的元素)。
  4. 处理 aria 状态:自定义命令没有固有语义,您必须处理 aria-pressed 或 aria-expanded 等状态。

示例:自定义动画控件

<!-- The target element that will respond to custom commands -->
<div id="action-target" class="target">
  Action Target
</div>

<!-- Buttons declaratively linked to the target element -->
<!-- Each button sends a unique custom command starting with '--' -->
<button commandfor="action-target" command="--spin">
  Spin
</button>

<button commandfor="action-target" command="--grow">
  Grow
</button>

<button commandfor="action-target" command="--reset">
  Reset All
</button>

<script>
  // 1. **Optional:** Define a registry of requested actions for cleaner logic
const commandRegistry = {
  '--spin': (target, source) => {
    const isSpun = target.classList.toggle('is-spun');
    // Set ARIA states, as custom commands have no inherent semantics.
    source?.setAttribute('aria-pressed', isSpun);
  },
  '--grow': (target, source) => {
    const isGrown = target.classList.toggle('is-grown');
    source?.setAttribute('aria-pressed', isGrown);
  },
  '--reset': (target) => {
    target.classList.remove('is-spun', 'is-grown');
    // Reset all associated buttons' ARIA states
    document.querySelectorAll(`button[commandfor="${target.id}"]`).forEach(btn => {
      btn.setAttribute('aria-pressed', 'false');
    });
  },
};

  // 2. **Mandatory:** Listen for the 'command' event directly on the target element
  // (This is necessary because the native 'command' event does not bubble)
  document.getElementById('action-target').addEventListener('command', (event) => {
    const command = event.command;
    const target = event.target;
    const source = event.source; // event.source refers to the triggering button
    const action = commandRegistry[command];

    if (action) {
      action(target, source);
    }
  });
</script>

主要限制

  • 为自定义命令添加前缀:强制性要求:所有自定义命令名称都必须以 -- 开头(例如 command="--my-action")。
  • 定位:commandfor 属性必须与同一文档树中元素的 id 匹配。
  • 无冒泡:command 事件不会冒泡。如果可能有多个目标,请向事件处理脚本添加 { capture: true },并监听祖先。
  • 影子根:如果目标可能位于影子根中,请使用 event.composedPath()[0],而不是 event.target。
  • 无障碍功能:自定义命令没有固有语义,您必须明确应用任何状态。

无障碍注意事项

  • 如果按钮显示额外内容,请使用 aria-expanded 属性,默认设置为 aria-expanded="false",在显示随播内容时设置为 aria-expanded="true"
  • 如果按钮本身可促成互动(例如,可播放和暂停的切换按钮,可静音和取消静音的音量按钮),请使用 aria-pressed 属性,在处于非活动状态时设置为 aria-pressed="false",在处于活动状态时设置为 aria-pressed="true"
  • 如果按钮打开的元素具有 menu、listbox、tree、grid 或 dialog 角色,则该控件必须具有 aria-haspopup 属性,且其值设置为所打开元素的角色
  • 如果按钮在激活时被停用,请使用 aria-disabled="true" 而不是 disabled,这样就不会失去焦点

后备策略

如果不支持 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);
  }
 });