Działania przycisków niestandardowych

Funkcje internetowe: polecenia wywołujące

Interfejs Invoker Commands API umożliwia przyciskom wywoływanie działań na elementach docelowych w sposób deklaratywny za pomocą atrybutów HTML. Takie podejście zmniejsza potrzebę ręcznego dodawania detektorów zdarzeń i oddziela interfejs od szczegółów implementacji. W przypadku niestandardowych działań związanych z aplikacją możesz zdefiniować własne nazwy poleceń. Polecenia niestandardowe muszą być poprzedzone podwójnym myślnikiem (--), aby uniknąć kolizji z przyszłymi wbudowanymi poleceniami przeglądarki.

Etapy wdrażania

  1. Zdefiniuj element docelowy: określ element, który będzie reagować na działanie. Jeśli nie ma unikalnego identyfikatora id, dodaj go.
  2. Skonfiguruj przycisk wywołujący: użyj atrybutu commandfor, aby wskazać element docelowy id, i atrybutu command, aby określić nazwę polecenia niestandardowego (z prefiksem --).
  3. Obsługa zdarzenia polecenia: dołącz detektor zdarzeń command bezpośrednio do elementu docelowego. Obiekt zdarzenia zawiera właściwości command i target (odnoszące się do elementu identyfikowanego przez commandfor).
  4. Obsługa stanów aria: polecenia niestandardowe nie mają własnej semantyki, więc musisz obsługiwać stany takie jak aria-pressed czy aria-expanded.

Przykład: niestandardowe elementy sterujące animacją

<!-- 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>

Ograniczenia kluczy

  • Prefiks poleceń niestandardowych: OBOWIĄZKOWY: wszystkie nazwy poleceń niestandardowych muszą zaczynać się od -- (np. command="--my-action").
  • Kierowanie: atrybut commandfor musi być zgodny z atrybutem id elementu w tym samym drzewie dokumentu.
  • Brak dymków: zdarzenie command nie jest wyświetlane jako dymek. Jeśli jest wiele możliwych elementów docelowych, dodaj { capture: true } do modułu obsługi zdarzeń i nasłuchuj elementu nadrzędnego.
  • Elementy shadow root: jeśli element docelowy może znajdować się w elemencie shadow root, użyj event.composedPath()[0] zamiast event.target.
  • Ułatwienia dostępu: polecenia niestandardowe nie mają własnej semantyki, więc musisz wyraźnie zastosować wszystkie stany.

Kwestie związane z ułatwieniami dostępu

  • Jeśli przycisk wyświetla dodatkowe treści, użyj atrybutu aria-expanded, który domyślnie jest ustawiony na aria-expanded="false", a gdy wyświetlane są treści towarzyszące – na aria-expanded="true".
  • Jeśli przycisk sam ułatwia interakcję (np. przyciski przełączania, które mogą odtwarzać i wstrzymywać, przyciski głośności, które mogą wyciszać i wyłączać wyciszenie), użyj atrybutu aria-pressed ustawionego na aria-pressed="false", gdy jest nieaktywny, i aria-pressed="true", gdy jest aktywny.
  • Jeśli przycisk otwiera element z rolą menu, listbox, tree, grid lub dialog, element sterujący musi mieć atrybut aria-haspopup z wartością ustawioną na rolę otwieranego elementu.
  • Jeśli przycisk zostanie wyłączony po aktywacji, zamiast disabled użyj aria-disabled="true", aby nie utracić zaznaczenia.

Strategie zastępcze

Jeśli interfejs Invoker Commands API nie jest obsługiwany, zdarzenie command nie zostanie wywołane. Aby zapewnić pełną obsługę we wszystkich nowoczesnych przeglądarkach, zalecamy użycie invokers-polyfill z https://github.com/keithamus/invokers-polyfill przez npm install lub CDN.

Ten polyfill w pełni obsługuje działania niestandardowe (począwszy od --) i wysyła zdarzenie command dokładnie tak samo jak natywny interfejs API.

Import dynamiczny (optymalizacja skuteczności)

Aby uzyskać najlepszą wydajność, wczytuj polyfill tylko wtedy, gdy przeglądarka nie obsługuje interfejsu API natywnie. Pozwala to zaoszczędzić przepustowość i skrócić czas wykonywania skryptów w przypadku użytkowników nowoczesnych przeglądarek.

UWAGA: ten polyfill nie zarządza stanami ARIA (takimi jak aria-pressed lub aria-expanded) w przypadku poleceń niestandardowych. Aby zapewnić dostępność witryny, musisz ręcznie zsynchronizować te stany w detektorze zdarzeń.

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

Ręczne przywracanie (wzorzec tradycyjny)

Jeśli nie chcesz używać kodu polyfill, możesz użyć kombinacji delegowania zdarzeń do wysyłania zdarzeń i rejestru poleceń do obsługi działań. Jest to powszechny wzorzec architektury w tradycyjnym programowaniu w języku JavaScript, który pozostaje bardzo wydajny i skalowalny.

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