Programação Por Equipe DevBras

Capa do artigo: Qual botão foi clicado: delegação de evento com closest e data-id

Qual botão foi clicado: delegação de evento com closest e data-id

Troque um ouvinte e um id por botão por um ouvinte só no contêiner, ache o botão certo com closest mesmo quando o clique cai no ícone e leve o identificador num data-id.

O carrinho tem trinta itens, cada um com um botão de remover que ganhou um id numerado, remover-1 até remover-30, e uma função própria. O cliente adiciona o trigésimo primeiro item sem recarregar a página e o botão dele não faz nada. Às vezes, nos outros, também não: o clique no ícone da lixeira chega com id vazio. A resposta curta: coloque um único ouvinte no contêiner da lista, use event.target.closest() para achar o botão e leia o identificador do item num atributo data-id. Item novo funciona sem nenhum código extra, e o ícone deixa de ser problema.

O exemplo deste guia é esse carrinho, com botões de remover e de aumentar a quantidade, primeiro em JavaScript puro e depois em jQuery.

Por que o id em cada botão quebra

Quando você liga um ouvinte em cada botão no carregamento da página, só os botões que existem naquele momento recebem o ouvinte. O item que entra depois, por AJAX, nasce sem nenhum. Você pode lembrar de ligar o ouvinte toda vez que insere um item, mas aí basta esquecer em um lugar.

O segundo problema é o alvo. Um clique no navegador acontece no elemento mais interno sob o ponteiro e depois sobe pelos ancestrais: do ícone para o botão, do botão para o item da lista, da lista para o resto da página. Esse caminho se chama propagação, e é ele que permite escutar no pai. Mas é também por causa dele que event.target pode ser o desenho da lixeira dentro do botão, e não o botão. Quem lê event.target.id recebe uma string vazia sempre que o clique cai no ícone, e "às vezes funciona".

O terceiro é o próprio id. Id precisa ser único na página. Quando o mesmo componente é renderizado duas vezes, por exemplo no carrinho lateral e no resumo, os ids se repetem, e getElementById devolve sempre o primeiro. O id do HTML serve para ligar rótulo, foco e âncora. Não é chave de banco.

Como fazer com um ouvinte só

  1. Use elementos button com type="button". Dentro de um formulário, botão sem tipo envia o formulário.
  2. Coloque a ação e o identificador do registro em atributos data-: data-acao="remover" e data-id="42".
  3. Ligue um único ouvinte de clique no contêiner que existe desde o carregamento, a lista do carrinho.
  4. No ouvinte, suba do alvo até o botão com closest('button[data-acao]').
  5. Se não achou botão, ou achou um fora da lista, ignore o clique.
  6. Leia dataset.acao e dataset.id e chame a função certa.
  7. No servidor, confira que o item pertence ao carrinho de quem pediu.

O HTML de um item

<ul id="carrinho">
  <li>
    <span>Caneca azul</span>
    <span>Quantidade: 1</span>
    <button type="button" data-acao="aumentar" data-id="42"
            aria-label="Aumentar quantidade de Caneca azul">+</button>
    <button type="button" data-acao="remover" data-id="42"
            aria-label="Remover Caneca azul">
      <svg aria-hidden="true" width="16" height="16"><use href="#icone-lixeira"></use></svg>
    </button>
  </li>
</ul>

O aria-label dá nome ao botão que só tem ícone e diz de qual item ele é. Sem isso, o leitor de tela anuncia trinta botões chamados "botão". O aria-hidden no desenho evita que ele seja lido como conteúdo.

O ouvinte em JavaScript puro

const carrinho = document.getElementById('carrinho');

carrinho.addEventListener('click', function (evento) {
  const botao = evento.target.closest('button[data-acao]');
  if (!botao || !carrinho.contains(botao)) return;

  const id = botao.dataset.id;
  if (botao.dataset.acao === 'remover') {
    removerItem(id, botao.closest('li'));
  } else if (botao.dataset.acao === 'aumentar') {
    aumentarQuantidade(id);
  }
});

async function removerItem(id, linha) {
  const resposta = await fetch('/carrinho/itens/' + encodeURIComponent(id), { method: 'DELETE' });
  if (resposta.ok) {
    linha.remove();
  }
}

Siga o clique no ícone. evento.target é o elemento use dentro do svg. O closest sobe a árvore a partir dele e para no primeiro ancestral que casa com o seletor, o botão de remover. evento.currentTarget, por sua vez, seria a lista inteira, porque é nela que o ouvinte está; ela não diz qual item foi clicado. O teste com contains é uma proteção para quando o contêiner está dentro de outro que também usa data-acao: o closest pode subir além da lista e achar um botão de fora.

O item 31, inserido depois, já funciona. Ele não precisa de ouvinte próprio, porque o clique dele sobe até a mesma lista.

O mesmo em jQuery

$('#carrinho').on('click', 'button[data-acao]', function () {
  const botao = $(this);
  const id = botao.data('id');
  if (botao.data('acao') === 'remover') {
    removerItem(id, botao.closest('li')[0]);
  }
});

Com o seletor como segundo argumento do on, o jQuery faz a delegação e entrega em this o botão que casou com o seletor, não o ícone. Um cuidado: .data('id') converte "42" no número 42, enquanto dataset.id devolve sempre texto. Se o identificador tem zeros à esquerda, como um código de produto "00042", leia com .attr('data-id').

Teclado e permissão

Um button de verdade dispara o mesmo evento de clique quando recebe Enter ou Espaço com foco. Um div com ouvinte de clique, não: ele nem recebe foco pelo Tab. Por isso a delegação funciona para teclado sem nenhuma linha extra, desde que o alvo seja botão. A diferença entre os eventos de ponteiro e o clique está em evento de mouse não cobre o teclado.

E o data-id está no HTML, então qualquer pessoa pode editar o valor nas ferramentas do navegador e pedir para remover o item 43, que é de outro cliente. O servidor precisa conferir que o item pertence ao carrinho da sessão antes de apagar. É a mesma regra de qualquer identificador que vem do navegador.

Erros comuns

  • Ler event.target.id direto. O clique no ícone traz o id vazio e a ação falha de forma intermitente.
  • Ligar ouvinte em cada botão no carregamento. Itens inseridos depois ficam sem ação.
  • Usar o id do HTML como chave do registro. Componente renderizado duas vezes repete o id e getElementById sempre acha o primeiro.
  • Botão sem type dentro de formulário. O clique em remover envia o formulário e recarrega a página.
  • stopPropagation num filho. O clique não sobe e a delegação no pai nunca fica sabendo.
  • Confiar no data-id no servidor. Qualquer pessoa troca o número e mexe no carrinho de outra.

Checklist

  • A lista tem um único ouvinte de clique.
  • O botão é achado com closest, não com event.target direto.
  • O identificador do registro está em data-id, não no id do HTML.
  • Todos os controles são button com type="button".
  • Botões só com ícone têm aria-label.
  • Um item inserido sem recarregar responde ao clique.
  • Enter e Espaço acionam a mesma ação que o clique.
  • O servidor confere o dono do registro antes de agir.

Adicione um item novo na sua lista sem recarregar a página e clique exatamente no ícone do botão dele. A ação encontrou o registro certo? E esse controle é um botão de verdade, que você alcança com Tab?

Avalie este artigo

Referência: http://forum.imasters.com.br/topic/380561-resolvidopegar-o-id-do-elemento-clicado-com-jquery/