إجراءات الأزرار المخصّصة

ميزات الويب: أوامر Invoker

تتيح واجهة 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. للحصول على دعم كامل في جميع المتصفّحات الحديثة، ننصحك باستخدام invokers-polyfill من https://github.com/keithamus/invokers-polyfill عبر npm install أو شبكة توصيل المحتوى (CDN).

يتيح هذا الرمز البرمجي استخدام الإجراءات المخصّصة بالكامل (بدءًا من --) ويرسل الحدث command تمامًا مثل واجهة برمجة التطبيقات الأصلية.

الاستيراد الديناميكي (تحسين الأداء)

للحصول على أفضل أداء، يجب تحميل 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);
  }
 });