การทำงานของปุ่มที่กำหนดเอง

ฟีเจอร์บนเว็บ: คำสั่ง Invoker

Invoker Commands API ช่วยให้ปุ่มต่างๆ ทริกเกอร์การดำเนินการกับองค์ประกอบเป้าหมายได้โดยใช้แอตทริบิวต์ HTML แนวทางนี้ช่วยลดความจำเป็นในการใช้ Listener เหตุการณ์ด้วยตนเอง และแยก UI ออกจากรายละเอียดการติดตั้งใช้งาน สำหรับการดำเนินการที่กำหนดเองและเจาะจงแอปพลิเคชัน คุณสามารถกำหนดชื่อคำสั่งของคุณเองได้ คำสั่งที่กำหนดเองต้องขึ้นต้นด้วยขีดกลาง 2 ขีด (--) เพื่อหลีกเลี่ยงการทับซ้อนกับคำสั่งในตัวของเบราว์เซอร์ในอนาคต

ขั้นตอนการติดตั้งใช้งาน

  1. กำหนดองค์ประกอบเป้าหมาย: ระบุองค์ประกอบที่จะตอบสนองต่อการดำเนินการ หากไม่มี id ที่ไม่ซ้ำกัน ให้เพิ่ม
  2. กำหนดค่าปุ่มเรียกใช้: ใช้แอตทริบิวต์ commandfor เพื่อชี้ไปยัง id ของเป้าหมาย และใช้แอตทริบิวต์ command เพื่อระบุชื่อคำสั่งที่กำหนดเอง (ขึ้นต้นด้วย --)
  3. จัดการเหตุการณ์คำสั่ง: แนบ Listener เหตุการณ์ 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 จะไม่เริ่มทำงาน เพื่อให้รองรับเบราว์เซอร์ที่ทันสมัยทั้งหมดได้อย่างเต็มรูปแบบ เราขอแนะนำให้ใช้ invokers-polyfill จาก https://github.com/keithamus/invokers-polyfill ผ่าน npm install หรือ CDN

Polyfill นี้รองรับการกระทำที่กำหนดเองอย่างเต็มรูปแบบ (เริ่มต้นด้วย --) และส่งเหตุการณ์ command เหมือนกับ API แบบเนทีฟทุกประการ

การนำเข้าแบบไดนามิก (การเพิ่มประสิทธิภาพ)

เพื่อประสิทธิภาพที่ดีที่สุด คุณควรโหลด Polyfill เฉพาะในกรณีที่เบราว์เซอร์ไม่รองรับ API ในตัว ซึ่งจะช่วยประหยัดแบนด์วิดท์และลดเวลาในการเรียกใช้สคริปต์สำหรับผู้ใช้ในเบราว์เซอร์ที่ทันสมัย

หมายเหตุ: Polyfill นี้ไม่ได้จัดการสถานะ ARIA (เช่น aria-pressed หรือ aria-expanded) สำหรับคำสั่งที่กำหนดเอง คุณต้องซิงค์สถานะเหล่านี้ด้วยตนเองใน Listener เหตุการณ์เพื่อให้มั่นใจว่าเว็บไซต์ของคุณเข้าถึงได้

// 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);
  }
 });