自訂按鈕動作

網頁功能: Invoker 指令

Invoker Commands API 可讓按鈕使用 HTML 屬性,以宣告方式觸發目標元素上的動作。這種做法可減少手動事件監聽器的需求,並將 UI 與實作詳細資料分離。如果是應用程式專屬的自訂動作,您可以自行定義指令名稱。自訂指令必須以雙破折號 (--) 開頭,以免與日後的內建瀏覽器指令衝突。

導入步驟

  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 } 新增至事件處理常式,並監聽祖系。
  • Shadow root:如果目標可能位於 Shadow root 中,請使用 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);
  }
 });