פעולות של כפתורים בהתאמה אישית

תכונות לאתרים: פקודות Invoker

‫Invoker Commands API מאפשר ללחצנים להפעיל פעולות ברכיבי יעד באופן הצהרתי באמצעות מאפייני HTML. הגישה הזו מפחיתה את הצורך ברכיבי event listener ידניים ומפרידה בין ממשק המשתמש לבין פרטי ההטמעה. לפעולות מותאמות אישית שספציפיות לאפליקציה, אתם יכולים להגדיר שמות פקודות משלכם. כדי למנוע התנגשויות עם פקודות מובנות עתידיות בדפדפן, פקודות בהתאמה אישית חייבות להתחיל במקף כפול (--).

שלבי ההטמעה

  1. הגדרת רכיב היעד: מזהים את הרכיב שיגיב לפעולה. אם אין לו id ייחודי, מוסיפים אחד.
  2. מגדירים את כפתור ההפעלה: משתמשים במאפיין commandfor כדי להפנות אל id של היעד, ובמאפיין command כדי לציין את שם הפקודה בהתאמה אישית (עם הקידומת --).
  3. טיפול באירוע הפקודה: מצרפים רכיב event 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 roots): אם היעד נמצא בשורש צל, צריך להשתמש ב-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 עם ערך שמוגדר לתפקיד של הרכיב שהוא פותח
  • אם הלחצן מושבת בזמן ההפעלה, במקום להשתמש ב-disabled, צריך להשתמש ב-aria-disabled="true" כדי שהמיקוד לא יאבד.

אסטרטגיות חלופיות

אם ה-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) לפקודות בהתאמה אישית. כדי לוודא שהאתר נגיש, צריך לסנכרן את המצבים האלה באופן ידני ברכיב ה-event 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);
  }
 });