Acciones de botones personalizados

Funciones web: Comandos del invocador

La API de Invoker Commands permite que los botones activen acciones en los elementos de destino de forma declarativa con atributos HTML. Este enfoque reduce la necesidad de objetos de escucha de eventos manuales y desvincula la IU de los detalles de implementación. Para las acciones personalizadas y específicas de la aplicación, puedes definir tus propios nombres de comandos. Los comandos personalizados deben tener el prefijo de un guion doble (--) para evitar colisiones con futuros comandos integrados del navegador.

Pasos de implementación

  1. Define el elemento objetivo: Identifica el elemento que responderá a la acción. Si no tiene un id único, agrégale uno.
  2. Configura el botón de invocación: Usa el atributo commandfor para apuntar al id del destino y el atributo command para especificar el nombre del comando personalizado (con el prefijo --).
  3. Controla el evento de comando: Adjunta un objeto de escucha de eventos command directamente en el elemento de destino. El objeto de evento contiene una propiedad command y una propiedad target (que hace referencia al elemento identificado por commandfor).
  4. Controla los estados de ARIA: Los comandos personalizados no tienen una semántica inherente, y debes controlar estados como aria-pressed o aria-expanded.

Ejemplo: Controles de animación personalizados

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

Restricciones clave

  • Prefijo de los comandos personalizados: OBLIGATORIO: Todos los nombres de los comandos personalizados deben comenzar con -- (p.ej., command="--my-action").
  • Segmentación: El atributo commandfor debe coincidir con el id de un elemento en el mismo árbol de documentos.
  • Sin burbujeo: El evento command no se propaga. Si hay varios destinos posibles, agrega { capture: true } al controlador de eventos y escucha en un elemento superior.
  • Raíces de sombra: Si el objetivo puede estar en una raíz de sombra, usa event.composedPath()[0] en lugar de event.target.
  • Accesibilidad: Los comandos personalizados no tienen una semántica inherente, por lo que debes aplicar cualquier estado de forma explícita.

Consideraciones de accesibilidad

  • Si el botón muestra contenido adicional, usa el atributo aria-expanded, establecido en aria-expanded="false" de forma predeterminada y en aria-expanded="true" cuando se muestra el contenido complementario.
  • Si el botón facilita una interacción en sí (por ejemplo, botones de activación que pueden reproducir y pausar, botones de volumen que pueden silenciar y activar el sonido), usa el atributo aria-pressed, establecido en aria-pressed="false" cuando está inactivo y en aria-pressed="true" cuando está activo.
  • Si el botón abre un elemento con un rol de menu, listbox, tree, grid o dialog, el control debe tener un atributo aria-haspopup, con un valor establecido en el rol del elemento que abre.
  • Si el botón se inhabilita cuando se activa, en lugar de usar disabled, usa aria-disabled="true" para que no se pierda el enfoque.

Estrategias de resguardo

Si no se admite la API de Invoker Commands, no se activará el evento command. Para obtener compatibilidad total con todos los navegadores actualizados, se recomienda usar el polyfill de invocadores de https://github.com/keithamus/invokers-polyfill a través de npm install o CDN.

Este polyfill admite por completo las acciones personalizadas (que comienzan con --) y despacha el evento command exactamente como la API nativa.

Importación dinámica (optimización del rendimiento)

Para obtener el mejor rendimiento, solo debes cargar el polyfill si el navegador no admite la API de forma nativa. Esto ahorra ancho de banda y reduce el tiempo de ejecución de la secuencia de comandos para los usuarios de navegadores modernos.

NOTA: Este polyfill no administra los estados de ARIA (como aria-pressed o aria-expanded) para los comandos personalizados. Debes sincronizar manualmente estos estados en tu objeto de escucha de eventos para garantizar que se pueda acceder a tu sitio.

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

Resguardo manual (patrón tradicional)

Si prefieres no usar un polyfill, puedes usar una combinación de delegación de eventos para enviar eventos y un registro de comandos para controlar las acciones. Este es un patrón de arquitectura común en el desarrollo tradicional de JavaScript que sigue siendo altamente eficiente y escalable.

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