Eventos: click, input, submit e muito mais

[67] Eventos: click, input, submit e muito mais

Ler e escrever no DOM é metade do trabalho; a outra metade é reagir. Aqui entram o addEventListener e o objeto que ele entrega ao callback, os eventos de mouse, teclado e formulário, o preventDefault que impede o submit de recarregar a página, e a delegação — um listener no pai que pega o filho que nem existe ainda.
Javascript

17 min de leitura

No artigo O que é o DOM e como o JavaScript interage com o HTML aprendemos a selecionar e modificar elementos do DOM. Mas um site estático que muda apenas quando o JavaScript carrega não é muito útil. O verdadeiro poder aparece quando o código reage ao usuário — quando ele clica, digita, envia um formulário, passa o mouse sobre um elemento, pressiona uma tecla.

Esse mecanismo de reação se chama evento. E entender eventos é entender como páginas web realmente funcionam.

O que é um evento?

Um evento é qualquer coisa que acontece no navegador — uma ação do usuário ou do próprio sistema. O navegador fica constantemente monitorando a página e, quando algo acontece, dispara um evento. Você pode "ouvir" esses eventos e executar código em resposta.

Exemplos de eventos:

  • Usuário clicou em um botão
  • Usuário digitou no campo de busca
  • Formulário foi enviado
  • Página terminou de carregar
  • Mouse passou por cima de um elemento
  • Tecla foi pressionada
  • Janela foi redimensionada

addEventListener — a forma correta

A maneira moderna e recomendada de registrar um evento:

const botao = document.querySelector("#meuBotao");

botao.addEventListener("click", function() {
  console.log("Botão clicado!");
});

// Com arrow function (mais comum no código moderno)
botao.addEventListener("click", () => {
  console.log("Botão clicado!");
});

A sintaxe é sempre:

elemento.addEventListener("nomeDoEvento", funcaoCallback);

A função callback é executada toda vez que o evento ocorre no elemento.

O objeto Event

Quando um evento é disparado, o navegador passa automaticamente um objeto com informações sobre esse evento para o callback:

const botao = document.querySelector("#meuBotao");

botao.addEventListener("click", (evento) => {
  console.log(evento.type);          // "click"
  console.log(evento.target);        // o elemento que foi clicado
  console.log(evento.target.id);     // "meuBotao"
  console.log(evento.timeStamp);     // momento em que ocorreu
  console.log(evento.clientX);       // posição X do mouse
  console.log(evento.clientY);       // posição Y do mouse
});

Por convenção, esse parâmetro é chamado de event, evt ou simplesmente e.

Eventos de mouse

const caixa = document.querySelector(".caixa");

// Clique simples
caixa.addEventListener("click", () => {
  console.log("Clicou!");
});

// Duplo clique
caixa.addEventListener("dblclick", () => {
  console.log("Duplo clique!");
});

// Mouse entrou no elemento
caixa.addEventListener("mouseenter", () => {
  caixa.classList.add("hover");
});

// Mouse saiu do elemento
caixa.addEventListener("mouseleave", () => {
  caixa.classList.remove("hover");
});

// Mouse se moveu sobre o elemento
caixa.addEventListener("mousemove", (e) => {
  console.log(`Posição: X=${e.clientX}, Y=${e.clientY}`);
});

// Botão do mouse pressionado
caixa.addEventListener("mousedown", () => {
  caixa.classList.add("pressionado");
});

// Botão do mouse solto
caixa.addEventListener("mouseup", () => {
  caixa.classList.remove("pressionado");
});

Eventos de teclado

const input = document.querySelector("#busca");

// Tecla pressionada (dispara continuamente se mantiver pressionada)
input.addEventListener("keydown", (e) => {
  console.log(`Tecla pressionada: ${e.key}`);
  console.log(`Código: ${e.code}`);

  // Detectar teclas especiais
  if (e.key === "Enter") {
    console.log("Enter pressionado — executar busca!");
  }
  if (e.key === "Escape") {
    input.value = "";
    console.log("Campo limpo.");
  }
});

// Tecla solta
input.addEventListener("keyup", (e) => {
  console.log(`Digitado até agora: ${input.value}`);
});

// Combinações de teclas
document.addEventListener("keydown", (e) => {
  if (e.ctrlKey && e.key === "s") {
    e.preventDefault(); // cancela o comportamento padrão (salvar página)
    console.log("Salvando dados...");
  }
});

Eventos de formulário

Os eventos de formulário são fundamentais para qualquer aplicação web:

const formulario = document.querySelector("#meuForm");
const campoNome = document.querySelector("#nome");
const campoEmail = document.querySelector("#email");

// Evento input — dispara a cada caractere digitado
campoNome.addEventListener("input", (e) => {
  console.log(`Nome sendo digitado: ${e.target.value}`);
});

// Evento change — dispara quando o campo perde foco E o valor mudou
campoEmail.addEventListener("change", (e) => {
  console.log(`E-mail confirmado: ${e.target.value}`);
});

// Evento focus — campo recebeu foco
campoNome.addEventListener("focus", () => {
  campoNome.classList.add("ativo");
});

// Evento blur — campo perdeu foco
campoNome.addEventListener("blur", () => {
  campoNome.classList.remove("ativo");
  // Bom momento para validar o campo
  if (campoNome.value.trim().length < 2) {
    console.log("Nome muito curto.");
  }
});

// Evento submit — formulário enviado
formulario.addEventListener("submit", (e) => {
  e.preventDefault(); // SEMPRE previna o comportamento padrão (recarregar a página)

  const dados = {
    nome: campoNome.value.trim(),
    email: campoEmail.value.trim(),
  };

  console.log("Formulário enviado:", dados);
});

O e.preventDefault() no submit é essencial — sem ele a página recarrega e você perde todos os dados.

Eventos da janela e documento

// Página completamente carregada (DOM + imagens + recursos)
window.addEventListener("load", () => {
  console.log("Página totalmente carregada.");
});

// DOM pronto (sem esperar imagens)
document.addEventListener("DOMContentLoaded", () => {
  console.log("DOM pronto. Pode manipular elementos.");
});

// Janela redimensionada
window.addEventListener("resize", () => {
  console.log(`Nova largura: ${window.innerWidth}px`);
});

// Usuário rolou a página
window.addEventListener("scroll", () => {
  const posicao = window.scrollY;
  if (posicao > 300) {
    document.querySelector("#voltarTopo").classList.remove("oculto");
  }
});

// Antes de sair da página
window.addEventListener("beforeunload", (e) => {
  e.preventDefault();
  // Alguns navegadores exibem um diálogo de confirmação
});

Delegação de eventos — Event Delegation

Um padrão fundamental e muito eficiente: em vez de adicionar um listener para cada elemento filho, você adiciona um único listener no elemento pai e usa event.target para identificar quem foi clicado.

// ❌ Ineficiente — um listener para cada item
const itens = document.querySelectorAll(".item");
itens.forEach(item => {
  item.addEventListener("click", () => {
    item.classList.toggle("selecionado");
  });
});

// ✅ Eficiente — um listener no pai
const lista = document.querySelector("#lista");

lista.addEventListener("click", (e) => {
  // Verifica se o clique foi em um <li>
  if (e.target.tagName === "LI") {
    e.target.classList.toggle("selecionado");
  }
});

A delegação de eventos é especialmente importante quando os elementos são criados dinamicamente — listeners adicionados antes da criação do elemento não funcionam, mas um listener no pai captura eventos de qualquer filho, inclusive os criados depois.

Removendo eventos

// Para remover um evento, a função deve ter nome (não pode ser anônima)
function aoClicar() {
  console.log("Clicado!");
}

const botao = document.querySelector("#botao");

// Adiciona
botao.addEventListener("click", aoClicar);

// Remove — usa exatamente a mesma referência de função
botao.removeEventListener("click", aoClicar);

Exemplo completo — barra de busca ao vivo

<!DOCTYPE html>
<html lang="pt-BR">
<head>
  <meta charset="UTF-8">
  <title>Busca ao Vivo</title>
  <style>
    body { font-family: sans-serif; max-width: 500px; margin: 2rem auto; padding: 1rem; }
    input { width: 100%; padding: .75rem; font-size: 1rem; border: 2px solid #ddd; border-radius: 8px; }
    input:focus { outline: none; border-color: royalblue; }
    ul { list-style: none; padding: 0; margin-top: 1rem; }
    li { padding: .5rem .75rem; border-bottom: 1px solid #eee; cursor: pointer; }
    li:hover { background: #f0f4ff; }
    .oculto { display: none; }
    .destaque { background: yellow; }
    #contador { color: gray; font-size: .9rem; margin-top: .5rem; }
  </style>
</head>
<body>
  <h2>🔍 Busca de Linguagens</h2>
  <input type="text" id="busca" placeholder="Digite para filtrar...">
  <p id="contador"></p>
  <ul id="lista"></ul>

  <script>
    const linguagens = [
      "JavaScript", "Python", "Java", "TypeScript",
      "Go", "Rust", "PHP", "Ruby", "Swift", "Kotlin",
      "C#", "C++", "Dart", "Scala", "Elixir"
    ];

    const input = document.querySelector("#busca");
    const lista = document.querySelector("#lista");
    const contador = document.querySelector("#contador");

    // Renderiza a lista com base em um filtro
    function renderizarLista(filtro = "") {
      const termo = filtro.toLowerCase().trim();

      const filtradas = linguagens.filter(lang =>
        lang.toLowerCase().includes(termo)
      );

      // Limpa a lista atual
      lista.innerHTML = "";

      // Cria novos itens
      filtradas.forEach(lang => {
        const li = document.createElement("li");

        // Destaca o termo buscado dentro do texto
        if (termo) {
          // escapa os caracteres especiais de regex — sem isto, digitar
          // "C++" monta /(c++)/ e o navegador lança SyntaxError
          const termoRegex = termo.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
          const regex = new RegExp(`(${termoRegex})`, "gi");
          li.innerHTML = lang.replace(regex, '<mark>$1</mark>');
        } else {
          li.textContent = lang;
        }

        lista.appendChild(li);
      });

      // Atualiza o contador
      contador.textContent = filtradas.length === 0
        ? "Nenhum resultado encontrado."
        : `${filtradas.length} linguagem(ns) encontrada(s).`;
    }

    // Renderiza tudo ao carregar
    renderizarLista();

    // Filtra a cada caractere digitado
    input.addEventListener("input", (e) => {
      renderizarLista(e.target.value);
    });

    // Limpa com Escape
    input.addEventListener("keydown", (e) => {
      if (e.key === "Escape") {
        input.value = "";
        renderizarLista();
        input.focus();
      }
    });

    // Delegação de evento na lista
    lista.addEventListener("click", (e) => {
      if (e.target.tagName === "LI" || e.target.tagName === "MARK") {
        const lang = e.target.closest("li").textContent;
        input.value = lang;
        renderizarLista(lang);
      }
    });
  </script>
</body>
</html>

Salve e abra no navegador. Você verá uma busca ao vivo funcionando com destaque de texto, contador e seleção por clique — tudo com eventos JavaScript puro.

Boas práticas com eventos

// ✅ 1. Sempre use addEventListener — nunca atributos HTML inline
// ❌ Ruim
// <button onclick="fazerAlgo()">Clique</button>

// ✅ Bom
botao.addEventListener("click", fazerAlgo);

// ✅ 2. Sempre chame preventDefault() no submit de formulários
formulario.addEventListener("submit", (e) => {
  e.preventDefault();
  // processar dados
});

// ✅ 3. Use delegação para listas e elementos dinâmicos
pai.addEventListener("click", (e) => {
  if (e.target.matches(".item")) {
    // agir sobre o item correto
  }
});

// ✅ 4. Nomeie suas funções de callback para poder removê-las depois
function tratarClique() { /* ... */ }
botao.addEventListener("click", tratarClique);
botao.removeEventListener("click", tratarClique);

// ✅ 5. Cuidado com scroll e resize — use debounce para evitar
//    excesso de chamadas (veremos isso em artigos futuros)

Tarefa para você

Crie uma página com um campo de texto e implemente:

  1. Exibir em tempo real quantos caracteres foram digitados
  2. Mudar a cor da borda para vermelho se passar de 50 caracteres
  3. Ao pressionar Enter, exibir o texto em um parágrafo abaixo
  4. Ao pressionar Escape, limpar o campo
  5. Exibir uma mensagem de boas-vindas quando o campo receber foco pela primeira vez (apenas uma vez — remova o listener depois)
Ver solução — os cinco comportamentos do campo, num listener por responsabilidade
// ---- index.html
// <div class="campo">
//   <input type="text" id="entrada" maxlength="200"
//          placeholder="Digite algo..." aria-describedby="contador">
//   <p id="contador" class="contador">0 caractere(s)</p>
//   <p id="aviso" class="aviso" role="status"></p>
//   <div id="saida"></div>
// </div>
//
// <style>
//   .contador.excedeu { color: #e53935; font-weight: 600; }
//   #entrada.limite   { border-color: #e53935; }
// </style>

// ---- app.js
const entrada = document.querySelector("#entrada");
const contador = document.querySelector("#contador");
const aviso = document.querySelector("#aviso");
const saida = document.querySelector("#saida");

const LIMITE = 50;

// ---------------------------------------------------------------
// 1 e 2 — contagem em tempo real e borda vermelha acima de 50
// ---------------------------------------------------------------
// `input` dispara a cada alteração do valor, inclusive colar com o
// mouse e ditado por voz. `keyup` perderia esses casos — e ainda
// contaria errado, porque dispara com Shift, setas e Ctrl.
entrada.addEventListener("input", () => {
  const quantidade = entrada.value.length;

  contador.textContent = `${quantidade} caractere(s)`;

  // toggle com segundo argumento: liga se a condição for verdadeira,
  // desliga se for falsa. Evita o par if/else só para trocar classe.
  const excedeu = quantidade > LIMITE;
  entrada.classList.toggle("limite", excedeu);
  contador.classList.toggle("excedeu", excedeu);
});

// ---------------------------------------------------------------
// 3 e 4 — Enter exibe, Escape limpa
// ---------------------------------------------------------------
entrada.addEventListener("keydown", (evento) => {
  // event.key é o caractere lógico ("Enter", "Escape", "a"). O antigo
  // event.keyCode está obsoleto e é o motivo de tanto código com 13 e
  // 27 mágicos espalhados.
  if (evento.key === "Enter") {
    evento.preventDefault(); // dentro de <form>, Enter submeteria

    const texto = entrada.value.trim();
    if (texto === "") return;

    const paragrafo = document.createElement("p");
    paragrafo.textContent = texto; // textContent: o usuário escreveu isto
    paragrafo.classList.add("linha");
    saida.appendChild(paragrafo);

    entrada.value = "";
    entrada.dispatchEvent(new Event("input")); // reaproveita o contador
  }

  if (evento.key === "Escape") {
    entrada.value = "";
    entrada.dispatchEvent(new Event("input"));
  }
});

// ---------------------------------------------------------------
// 5 — boas-vindas no primeiro foco, uma vez só
// ---------------------------------------------------------------
// { once: true } faz o próprio navegador remover o listener depois de
// disparar. É mais confiável do que uma flag booleana, que alguém
// esquece de checar em algum caminho do código.
entrada.addEventListener(
  "focus",
  () => {
    aviso.textContent = `Bem-vinda! O limite confortável é de ${LIMITE} caracteres.`;
    setTimeout(() => (aviso.textContent = ""), 4000);
  },
  { once: true }
);

// A forma manual, para quando a condição de remoção não é "a primeira
// vez" — repare que a função precisa ter nome para poder ser removida:
function saudarUmaVez() {
  console.log("só uma vez");
  entrada.removeEventListener("focus", saudarUmaVez);
}
// entrada.addEventListener("focus", saudarUmaVez);

// ---------------------------------------------------------------
// O detalhe que morde: removeEventListener precisa da MESMA função
// ---------------------------------------------------------------
// Cada arrow escrita no lugar cria uma função nova. Estas duas não são
// a mesma referência, então a remoção não remove nada:
entrada.addEventListener("blur", () => console.log("saiu"));
entrada.removeEventListener("blur", () => console.log("saiu")); // não faz nada

// O listener continua lá, e a cada re-render que adicionar outro, o
// mesmo evento passa a disparar duas, três, dez vezes. É a causa mais
// comum de "o clique executou várias vezes".

Guarde a referência da função (ou use { once: true } / AbortController) sempre que o listener puder ser removido. Arrow anônima em removeEventListener é uma linha que não faz nada e não avisa — o vazamento só aparece quando o evento dispara em dobro.

A delegação de eventos é o que separa o código que funciona com dez elementos do que funciona com dez mil: em vez de um ouvinte por item, um ouvinte no pai, e o event.target informa quem foi. Ao lado dela ficam duas peças que costumam ser mal usadas — o preventDefault, que só faz sentido quando existe um comportamento padrão a impedir, e o removeEventListener, que exige a mesma referência de função usada no registro, razão pela qual uma arrow escrita direto na chamada nunca poderá ser removida.

Fontes e Referências

Exercícios

Exercício 1

O usuário cola um texto no campo com o botão direito do mouse, sem tocar no teclado. Quais dos três listeners disparam?

const campo = document.querySelector("#busca");

campo.addEventListener("input",  () => console.log("A"));
campo.addEventListener("keyup",  () => console.log("B"));
campo.addEventListener("change", () => console.log("C"));
Ver resposta

✓ Resposta: Dispara só o A, na hora da colagem. O input é o evento do valor mudou, não importa como: digitação, colar com o mouse, arrastar texto para dentro do campo, autopreenchimento do navegador, ditado por voz. O B não dispara porque nenhuma tecla foi solta — e é por isso que contador de caracteres feito em keyup ignora colagens e, pior, também dispara em Shift, setas e Ctrl, onde nada mudou. O C ainda não disparou: o change em campo de texto só acontece quando o campo perde o foco e o valor está diferente de quando ele o recebeu. Clique fora e o C aparece; clique fora sem ter alterado nada e ele não aparece. Regra prática: input para reagir enquanto se digita, change para confirmar um valor fechado.

Exercício 2

Esta era a linha do exemplo de busca ao vivo antes de ganhar o escape. Com a lista do artigo carregada, o usuário digita C++. O que acontece com a página?

const regex = new RegExp(`(${termo})`, "gi");
li.innerHTML = lang.replace(regex, "<mark>$1</mark>");
Ver resposta

✓ Resposta: A lista fica vazia e o contador congela na busca anterior. O filtro em si funciona: includes("c++") encontra o item "C++". O que quebra é a linha seguinte — o termo é interpolado direto na expressão regular, produzindo /(c++)/gi, e em JavaScript não existe quantificador possessivo: o segundo + não tem o que repetir, e o construtor lança SyntaxError: Invalid regular expression: Nothing to repeat. A exceção interrompe renderizarLista no meio, e o lista.innerHTML = "" já tinha rodado — daí a tela em branco. A correção é escapar o termo antes, com termo.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"). Vale a lição geral: toda vez que texto do usuário vira sintaxe — regex, SQL, HTML, caminho de arquivo —, ele precisa ser escapado para a sintaxe de destino.

Exercício 3

O listener está na <ul> e o usuário clica bem em cima do trecho destacado, dentro do <mark>. O que sai em cada linha?

<ul id="lista">
  <li>Java<mark>Script</mark></li>
</ul>
lista.addEventListener("click", (e) => {
  console.log(e.target.tagName);        // A
  console.log(e.currentTarget.tagName); // B
  console.log(e.target.matches("li"));  // C
});
Ver resposta

✓ Resposta: A é "MARK", B é "UL" e C é false. O target é o elemento mais fundo onde o clique realmente aterrissou; o currentTarget é o elemento em que este listener está pendurado, e é ele que não muda ao longo da subida do evento. Aí está a armadilha central da delegação: testar e.target.tagName === "LI" funciona enquanto o item é texto puro e falha no dia em que alguém põe um <mark>, um <span> ou um ícone dentro dele — o clique passa a chegar no filho. A forma que não quebra é subir a partir do alvo: const item = e.target.closest("li"); if (item) { ... }. O closest começa no próprio elemento e sobe pelos ancestrais até achar quem casa com o seletor, devolvendo null se ninguém casar — daí o teste ser obrigatório, porque cliques no espaçamento da <ul> também acionam o listener.

Exercício 4

Depois destas quatro linhas, quantas vezes "oi" é impresso a cada clique?

botao.addEventListener("click", () => console.log("oi"));
botao.removeEventListener("click", () => console.log("oi"));

function saudar() { console.log("oi"); }
botao.addEventListener("click", saudar);
botao.addEventListener("click", saudar);
Ver resposta

✓ Resposta: Duas vezes. A remoção da segunda linha não remove nada: cada arrow escrita no código é uma função nova, e duas funções com o corpo idêntico continuam sendo objetos diferentes. O removeEventListener compara por referência, não por texto, então o primeiro listener segue registrado e conta 1. Já saudar, adicionado duas vezes com a mesma referência, é registrado uma só vez — o navegador ignora o par idêntico de tipo, função e opções — e conta mais 1. Vale corrigir a formulação comum de que "a função precisa ter nome para poder ser removida": o que precisa é da mesma referência. const saudar = () => {...} é uma arrow sem nome próprio e se remove sem problema, porque você guardou a referência numa variável. Quando isso incomoda, há duas saídas melhores: { once: true } no terceiro argumento, que faz o navegador remover sozinho após o primeiro disparo, e o AbortController, que desliga vários listeners de uma vez com um único controller.abort().

Exercício 5

Ao enviar o formulário, o objeto pisca no console e some, e a página parece voltar ao começo. O que está faltando — e mover o listener para o click do botão resolveria?

form.addEventListener("submit", (e) => {
  const dados = { nome: campoNome.value.trim() };
  console.log(dados);
});
Ver resposta

✓ Resposta: Falta e.preventDefault(). Sem ele, o navegador executa a ação padrão do formulário depois do listener: uma requisição para a própria URL, que recarrega a página. O código rodou e o console.log aconteceu de verdade — o que apagou a saída foi o recarregamento, e é por isso que o sintoma engana tanto (ligar o Preserve log do DevTools faz a mensagem sobreviver e mostra que nunca houve erro). Mover o listener para o click do botão não resolve: dentro de um <form>, um <button> sem type vale type="submit", então o clique continua submetendo. Forçar type="button" até impede o recarregamento, mas custa caro: some o envio pelo Enter e some a validação nativa dos campos required. Ouvir o submit e prevenir o padrão é o caminho que preserva a semântica do formulário e cancela apenas a navegação.

Comentários

Mais em Javascript

Revisão + Projeto Final: Produção Real
Revisão + Projeto Final: Produção Real

Deploy, segurança e performance só existem depois que a aplicação existe, e…

Deploy: do código ao ar
Deploy: do código ao ar

Construir é metade do trabalho; a outra metade é colocar no ar. O artigo…

Introdução ao Node.js: JavaScript fora do navegador
Introdução ao Node.js: JavaScript fora do navegador

Em 2009 o JavaScript saiu da aba do navegador. O Node roda o mesmo motor V8…