12k
All articles

Controlando Web Components Sem JavaScript

Use comandos invocadores personalizados para controlar web components só com HTML. command e commandfor ligam botões a um elemento sem JavaScript.

OpenReplay Team
OpenReplay Team
Controlando Web Components Sem JavaScript

Os comandos de invocação personalizados permitem que um custom element exponha suas ações de forma declarativa: o autor do componente escreve um único listener do evento command em JavaScript, e todos que usam o componente conectam botões a ele com os atributos HTML command e commandfor, sem nenhum script do lado de quem consome.

Distribuir um custom element geralmente arrasta junto uma seção de README: aquela parte que explica quais métodos chamar, ou qual atributo data-action espalhar pelos botões. O componente funciona; a fricção está na ligação entre as partes.

Este artigo começa onde nosso guia da Invoker Commands API termina. Aquele texto cobre os comandos nativos para dialogs e popovers (show-modal, close, toggle-popover e companhia) e o básico do CommandEvent; nada disso é repetido aqui. Os invoker commands são Baseline Newly available desde 12 de dezembro de 2025, então também não há seção sobre polyfill. Em vez disso, construímos um elemento distribuível, <code-viewer>, cuja API pública é o seu conjunto de comandos.

Principais Conclusões

  • Nomes de comandos personalizados precisam começar com dois traços, como --expand; o prefixo é um namespace reservado, então um comando personalizado nunca poderá colidir com um comando nativo que o navegador venha a adicionar depois.
  • O evento command é disparado diretamente no elemento indicado por commandfor, não sofre bubbling e não cruza fronteiras de shadow DOM, portanto o listener pertence ao próprio componente.
  • Dentro do handler, event.command carrega o nome do comando e event.source é o botão que o disparou, que é onde aria-pressed e aria-expanded devem ficar.
  • Um componente não pode ser alvo de commandfor a partir de dentro de sua própria shadow tree, porque o host não possui id ali; a propriedade commandForElement aceita uma referência direta ao elemento no lugar do id.
  • A especificação não define nenhuma mudança de estado para comandos personalizados, então o handler precisa manter o estado ARIA por conta própria.

O Autor Escreve JavaScript, o Consumidor Escreve HTML

Essa divisão de trabalho é toda a ideia por trás dos comandos de invocação personalizados. Você, autor do componente, escreve o listener de command uma única vez, dentro do elemento. Todo consumidor depois disso controla o componente pela marcação:

<button command="--expand" commandfor="snippet">Expand</button>

Nenhum import além do próprio componente, nenhum nome de método para memorizar, nenhuma delegação de eventos para escrever à mão. O conjunto de comandos se torna a interface pública do elemento, documentada da mesma forma que command="show-modal" é documentado para <dialog>.

Por Que Métodos e Atributos data-* Ficam Aquém?

Os dois padrões que autores de componentes distribuem hoje empurram trabalho para o consumidor. Um método imperativo força todo consumidor a escrever JavaScript:

document.querySelector('#snippet').expand();
// plus a click listener on every button that should call it

Um atributo data-action sob medida mantém a marcação declarativa, mas obriga você a reimplementar o despacho: um listener de clique delegado, uma convenção de parsing de atributos e documentação para um vocabulário que só o seu componente entende. Nenhuma das abordagens lhe dá algo vindo da plataforma. Com command/commandfor você herda a semântica de um botão de verdade, ativação por teclado incluída, e uma convenção de ligação compartilhada com todos os outros elementos controlados por comandos na página.

Como Definir um Comando de Invocação Personalizado?

Valores de comandos personalizados precisam começar com dois traços, e esse prefixo é reservado por definição. Nos estados do atributo command, qualquer valor que comece com -- é classificado como palavra-chave personalizada, o que impede que um comando nativo assuma essa forma um dia, de modo que seus comandos não podem colidir com nada que o navegador adicione posteriormente. Um valor que não seja nem uma palavra-chave nativa nem prefixado com -- é inválido e não dispara nada.

Nosso <code-viewer> expõe três comandos: --expand, --toggle-wrap e --copy. Essa lista, e não um conjunto de métodos, é o que sua documentação anuncia.

ComandoO que fazEstado ARIA a atualizar no botão
--expandAlterna o atributo expanded no elementoaria-expanded
--toggle-wrapAlterna o atributo wrap no elementoaria-pressed
--copyEscreve o conteúdo de texto do elemento na área de transferêncianenhum

Onde o Evento command É Disparado?

O evento command é disparado no elemento alvo indicado por commandfor, não no botão, e não sofre bubbling, de modo que uma delegação na fase de bubbling em um ancestral não o enxergará. Anexe o listener ao elemento que recebe o comando; a interface CommandEvent fornece ao handler duas propriedades além das do evento base: event.command guarda o nome do comando, e event.source aponta de volta para o botão invocador.

class CodeViewer extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `<pre><slot></slot></pre>`;
    this.addEventListener('command', this.#onCommand);
  }

  #onCommand = (event) => {
    switch (event.command) {
      case '--expand':
        // state change goes here
        break;
      case '--toggle-wrap':
        break;
      case '--copy':
        navigator.clipboard.writeText(this.textContent);
        break;
    }
  };
}

Faça o despacho sobre event.command com uma lista explícita de casos e sem um default permissivo. Qualquer valor prefixado com -- dispara o evento, você o trate ou não, e nada lança exceção quando você não trata. Session replays de componentes ligados declarativamente mostram esse modo de falha como um botão morto: o clique acontece, nada muda na tela e nenhum erro aparece no console, que é exatamente a aparência, do lado do usuário, de um valor de comando digitado errado ou de um listener no nó errado.

Shadow DOM: Direcionando o Host com commandForElement

Um componente não pode ser alvo de commandfor a partir de dentro de sua própria shadow tree. O atributo commandfor só resolve ids que existem na mesma árvore do botão, e o host, que fica lá fora no light DOM, não tem id dentro de seu próprio shadow root. A propriedade commandForElement fecha essa lacuna: ela aceita uma referência direta ao elemento, atravessando shadow roots, em vez de um id. Assim, um botão interno pode passar pelo mesmo handler de comandos:

connectedCallback() {
  const copyBtn = document.createElement('button');
  copyBtn.textContent = 'Copy';
  copyBtn.setAttribute('command', '--copy');
  copyBtn.commandForElement = this; // no id needed
  this.shadowRoot.append(copyBtn);
}

Dois fatos sobre propagação importam aqui. O evento é disparado diretamente no alvo, sem bubbles nem composed definidos, então ele nunca cruza uma fronteira de shadow DOM e event.target é sempre o elemento que o recebeu; nenhuma ginástica com composedPath() é necessária. E event.source é retargetado em relação à árvore do listener: para aquele botão interno, um listener no host enxerga o host, não o botão, então mantenha uma referência direta aos botões internos caso precise atualizá-los.

Estado e ARIA São Responsabilidade Sua

A especificação não define nenhuma mudança de estado para valores de comandos personalizados; o único comportamento do navegador é despachar o evento. Nada define aria-pressed ou aria-expanded por você, então atualize-os em event.source no mesmo ramo que altera o estado:

case '--expand': {
  const expanded = this.toggleAttribute('expanded');
  event.source.setAttribute('aria-expanded', expanded);
  break;
}
case '--toggle-wrap': {
  const wrapped = this.toggleAttribute('wrap');
  event.source.setAttribute('aria-pressed', wrapped);
  break;
}

Para os botões no light DOM do consumidor isso é seguro: botão e listener compartilham a mesma árvore, então event.source é o botão real.

O Elemento Finalizado e o HTML Que o Controla

Montado, o componente é uma classe com um único listener:

class CodeViewer extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `<pre><slot></slot></pre>`;
    this.addEventListener('command', this.#onCommand);
  }

  #onCommand = (event) => {
    switch (event.command) {
      case '--expand': {
        const expanded = this.toggleAttribute('expanded');
        event.source.setAttribute('aria-expanded', expanded);
        break;
      }
      case '--toggle-wrap': {
        const wrapped = this.toggleAttribute('wrap');
        event.source.setAttribute('aria-pressed', wrapped);
        break;
      }
      case '--copy':
        navigator.clipboard.writeText(this.textContent);
        break;
    }
  };
}
customElements.define('code-viewer', CodeViewer);

E isto é tudo o que um consumidor escreve:

<code-viewer id="snippet">const answer = 42;</code-viewer>

<button command="--expand" commandfor="snippet" aria-expanded="false">Expand</button>
<button command="--toggle-wrap" commandfor="snippet" aria-pressed="false">Wrap</button>
<button command="--copy" commandfor="snippet">Copy</button>

Zero JavaScript do lado do consumidor. Os botões podem ficar em qualquer lugar do documento, em qualquer ordem, adicionados ou removidos à vontade.

Conclusão

Um conjunto de comandos é uma API pública menor e mais durável do que uma superfície de métodos: é inspecionável na marcação, acessível por teclado por padrão e isolada em um namespace, de modo que a plataforma nunca poderá quebrá-la. Escolha um componente que você atualmente controla por métodos ou convenções data-*, mova suas ações para trás de comandos prefixados com -- e deixe que o próximo consumidor faça a ligação sem abrir uma tag de script.

Perguntas Frequentes

Qual é a diferença entre commandfor e popovertarget?

Os atributos command e commandfor substituem e generalizam popovertarget e popovertargetaction. O par mais antigo apenas mostra, esconde ou alterna popovers, enquanto command e commandfor também controlam dialogs com valores como show-modal e close, além de despacharem comandos personalizados com dois traços para qualquer elemento. Os atributos mais novos suportam tudo o que os antigos suportavam, então código novo deve preferir command e commandfor.

command e commandfor funcionam em elementos além de button?

Não. A especificação HTML define command e commandfor apenas no elemento button, e as propriedades IDL correspondentes, command e commandForElement, existem em HTMLButtonElement. Links, inputs e outros elementos não podem atuar como invocadores. Um custom element que encapsula um button nativo também não ganha comportamento de invocador de graça: o button nativo interno precisa carregar os atributos ele mesmo.

Um botão com command envia o formulário ao qual pertence?

Não, e isso corta dos dois lados. Um botão que carrega command ou commandfor sem type explícito não é um botão de submit, então ele não enviará o formulário. Ele também não executará o comando: com um form owner presente, o type fica no estado Auto e a ativação retorna antes de o comando ser disparado, de modo que o botão não faz absolutamente nada. Defina type='button' em botões invocadores dentro de um formulário. A especificação marca essa restrição como uma medida de compatibilidade que pretende remover.

Posso tratar eventos command com um listener delegado em um ancestral?

Apenas na fase de captura. O evento command não sofre bubbling, então um listener delegado comum em um ancestral nunca é disparado. Listeners na fase de captura executam no caminho de descida até o alvo, então addEventListener('command', handler, { capture: true }) em um container captura eventos command despachados para seus descendentes na mesma árvore. O evento nunca cruza fronteiras de shadow DOM, então a delegação para nos shadow roots.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.