Nesta aula, vamos construir uma calculadora funcional em JavaScript, explorando conceitos essenciais como lógica de programação, manipulação de eventos, tratamento de entradas e casos extremos (edge cases). Este projeto prático consolida habilidades fundamentais para qualquer desenvolvedor front-end, unindo HTML, CSS e JavaScript de forma integrada.

Você aprenderá a estruturar o código de forma clara, lidar com interações do usuário, validar dados e garantir que a calculadora se comporte corretamente mesmo em situações inesperadas. Ao final, terá um projeto completo que pode ser estendido e personalizado.

Lógica

A lógica de uma calculadora envolve armazenar o primeiro operando, o operador e o segundo operando, e então realizar a operação quando o usuário pressiona igual. Vamos implementar uma máquina de estados simples, onde a calculadora pode estar em diferentes modos: aguardando o primeiro número, aguardando o operador, aguardando o segundo número, ou exibindo o resultado.

Uma abordagem comum é manter variáveis de estado: currentValue (o valor exibido), previousValue (o primeiro operando), operator (o operador selecionado) e shouldResetDisplay (um flag que indica se o display deve ser limpo ao digitar o próximo número). A cada tecla pressionada, atualizamos essas variáveis de acordo com a transição de estado.

Por exemplo, quando o usuário digita um número, se shouldResetDisplay for verdadeiro, limpamos o display e o definimos como falso. Quando o usuário pressiona um operador, armazenamos o valor atual como previousValue, definimos o operador e ativamos o reset do display. Ao pressionar igual, calculamos o resultado usando uma função que executa a operação adequada.

Vamos criar uma função calculate que recebe dois números e um operador, e retorna o resultado, tratando divisão por zero e outras operações inválidas.

function calculate(num1, num2, operator) {
  const a = parseFloat(num1);
  const b = parseFloat(num2);
  switch (operator) {
    case '+': return a + b;
    case '-': return a - b;
    case '*': return a * b;
    case '/':
      if (b === 0) return 'Erro';
      return a / b;
    default: return null;
  }
}

Essa função será usada quando o usuário pressionar igual ou quando um operador for pressionado em sequência (para encadear operações). No projeto, também tratamos o caso de múltiplas operações seguidas, como 2 + 3 + 4, que deve ser resolvido da esquerda para a direita.

Eventos

Os eventos são a ponte entre a interface e a lógica. No JavaScript, usamos o método addEventListener para escutar cliques nos botões da calculadora. Podemos selecionar todos os botões numéricos e de operadores e atribuir a eles a função adequada.

Vamos estruturar nosso HTML com botões que possuem classes ou data attributes para identificação. Por exemplo, cada botão de número tem a classe num, cada operador tem a classe op, e botões especiais como igual, limpar e ponto decimal têm ids ou classes próprias.

<button class="btn num" data-num="7">7</button>
<button class="btn op" data-op="+">+</button>
<button class="btn" id="equals">=</button>
<button class="btn" id="clear">C</button>

No JavaScript, selecionamos todos os botões com document.querySelectorAll('.num') e iteramos sobre eles, adicionando um listener de clique. Dentro do listener, acessamos o valor do botão via dataset.num e chamamos a função inputNumber.

document.querySelectorAll('.num').forEach(button => {
  button.addEventListener('click', () => {
    inputNumber(button.dataset.num);
  });
});

Da mesma forma, para os operadores, chamamos inputOperator com o operador correspondente. Para o igual, chamamos calculateResult, e para limpar, clearCalculator.

Uma boa prática é usar a delegação de eventos: em vez de adicionar um listener para cada botão, adicionamos um único listener ao contêiner da calculadora e verificamos o alvo do evento. Isso é mais eficiente e facilita a manutenção, especialmente se novos botões forem adicionados dinamicamente.

const calculator = document.getElementById('calculator');
calculator.addEventListener('click', (e) => {
  const target = e.target;
  if (target.classList.contains('num')) {
    inputNumber(target.dataset.num);
  } else if (target.classList.contains('op')) {
    inputOperator(target.dataset.op);
  } else if (target.id === 'equals') {
    calculateResult();
  } else if (target.id === 'clear') {
    clearCalculator();
  }
});

Além disso, podemos adicionar eventos de teclado para que a calculadora funcione com o teclado físico, melhorando a acessibilidade. Escutamos o evento keydown no documento e mapeamos teclas para ações.

document.addEventListener('keydown', (e) => {
  if (e.key >= '0' && e.key <= '9') inputNumber(e.key);
  if (['+', '-', '*', '/'].includes(e.key)) inputOperator(e.key);
  if (e.key === 'Enter' || e.key === '=') calculateResult();
  if (e.key === 'Escape' || e.key.toLowerCase() === 'c') clearCalculator();
  if (e.key === '.') inputDecimal();
  if (e.key === 'Backspace') deleteLast();
});

Tratamento de entrada

O tratamento de entrada é crucial para uma boa experiência do usuário. Devemos controlar o que é exibido no display e evitar entradas inválidas, como múltiplos pontos decimais ou números começando com zero desnecessário.

Primeiro, vamos gerenciar o display: ele deve mostrar o valor atual enquanto o usuário digita. Quando o usuário pressiona um operador, o display pode mostrar o primeiro operando e o operador, mas é comum manter apenas o valor atual até que o segundo número seja digitado. Em nossa implementação, usamos a variável currentValue para armazenar a string exibida no display.

Na função inputNumber, verificamos se o display deve ser resetado (após um operador ou igual). Se sim, substituímos o conteúdo por apenas o dígito pressionado; caso contrário, concatenamos o dígito ao valor atual. Também limitamos o comprimento do número para evitar overflow de tela.

function inputNumber(digit) {
  if (shouldResetDisplay) {
    display.textContent = digit;
    shouldResetDisplay = false;
  } else {
    // Evita vários zeros à esquerda
    if (display.textContent === '0' && digit !== '.') {
      display.textContent = digit;
    } else {
      if (display.textContent.length < 12) {
        display.textContent += digit;
      }
    }
  }
}

Para o ponto decimal, verificamos se já existe um ponto no valor atual. Se não houver, adicionamos; se houver, ignoramos. Também tratamos o caso de o display estar vazio ou em estado de reset.

function inputDecimal() {
  if (shouldResetDisplay) {
    display.textContent = '0.';
    shouldResetDisplay = false;
  } else if (!display.textContent.includes('.')) {
    display.textContent += '.';
  }
}

Além disso, implementamos a função de limpar (clearCalculator) que reseta todas as variáveis de estado e exibe '0'. Também podemos adicionar uma função de apagar o último dígito (deleteLast), que remove o último caractere do display.

function deleteLast() {
  if (shouldResetDisplay) return;
  let current = display.textContent;
  if (current.length > 1) {
    display.textContent = current.slice(0, -1);
  } else {
    display.textContent = '0';
  }
}

Outro aspecto é o armazenamento do operador e do primeiro operando. Quando o usuário pressiona um operador, devemos guardar o valor atual como previousValue e definir o operador. Se já houver um operador pendente e o usuário pressionar outro, calculamos o resultado parcial e atualizamos o display.

function inputOperator(op) {
  if (operator && !shouldResetDisplay) {
    // Calcula o resultado parcial se já houver um operador
    const result = calculate(previousValue, display.textContent, operator);
    display.textContent = result;
    previousValue = result;
  } else {
    previousValue = display.textContent;
  }
  operator = op;
  shouldResetDisplay = true;
}

Edge cases

Edge cases são situações inesperadas que podem quebrar a calculadora se não forem tratadas. Vamos listar os principais e como lidamos com eles:

  • Divisão por zero: Retornamos a string 'Erro' e exibimos no display. O usuário deve limpar para continuar.
  • Múltiplos pontos decimais: Impedimos que o usuário digite dois pontos no mesmo número.
  • Números com muitos dígitos: Limitamos o display a 12 caracteres para evitar que o layout quebre.
  • Pressionar operador logo após igual: Se o usuário pressionar igual e depois um operador, o resultado deve ser usado como primeiro operando para a próxima operação.
  • Pressionar operador múltiplas vezes seguidas: Deve atualizar o operador sem alterar o primeiro operando.
  • Pressionar igual sem operador: Simplesmente não faz nada ou repete o último resultado.
  • Pressionar ponto decimal no início: Deve exibir '0.' para facilitar a entrada.
  • Pressionar zero repetidamente: Evita múltiplos zeros à esquerda (ex.: '0007').
  • Entrada de teclado com caracteres não numéricos: Ignoramos teclas que não correspondem a ações.

Vamos implementar essas verificações no código. Por exemplo, na função calculateResult, se não houver operador, simplesmente retornamos sem fazer nada. Se o resultado for 'Erro', mantemos o display como está.

function calculateResult() {
  if (operator === null || shouldResetDisplay) return;
  const result = calculate(previousValue, display.textContent, operator);
  display.textContent = result;
  previousValue = null;
  operator = null;
  shouldResetDisplay = true;
}

Também tratamos a divisão por zero na função calculate, retornando 'Erro'. No display, esse valor é mostrado, e qualquer nova operação deve ser limpa antes de continuar. Podemos também desabilitar botões de operação quando o display mostra 'Erro', mas é mais simples apenas permitir que o usuário pressione C.

Outro edge case é quando o usuário pressiona um operador após um resultado de erro. Nesse caso, devemos resetar o estado para evitar comportamentos estranhos. Vamos verificar se o display contém 'Erro' e, se sim, tratar como início de nova operação.

function inputOperator(op) {
  if (display.textContent === 'Erro') {
    clearCalculator();
  }
  // ... resto da lógica
}

Boas práticas e observações finais

Ao construir uma calculadora, é importante manter o código organizado e legível. Use funções claras para cada ação, evite duplicação de código e comente trechos complexos. Além disso, lembre-se de testar todos os edge cases manualmente para garantir a robustez.

Uma boa prática é separar a lógica de negócio da manipulação do DOM. Assim, você pode testar as funções de cálculo isoladamente com um framework de testes, como Jest. Também é recomendável usar constantes para valores fixos e evitar números mágicos.

Outra dica é usar o operador de igualdade estrita (===) para comparar valores e evitar conversões implícitas que podem causar bugs. Por fim, considere adicionar animações sutis nos botões para melhorar a experiência do usuário, mas lembre-se de que a funcionalidade vem primeiro.

Referências

Exercícios

  1. Implemente a função calculate que recebe dois números e um operador e retorna o resultado. Teste com divisão por zero e retorne 'Erro' nesse caso. Resposta:

    ✓ Resposta:
    function calculate(num1, num2, operator) {
      const a = parseFloat(num1);
      const b = parseFloat(num2);
      switch (operator) {
        case '+': return a + b;
        case '-': return a - b;
        case '*': return a * b;
        case '/':
          if (b === 0) return 'Erro';
          return a / b;
        default: return null;
      }
    }
  2. Escreva uma função inputNumber que recebe um dígito e o adiciona ao display, considerando o estado de reset e evitando múltiplos zeros à esquerda. Resposta:

    ✓ Resposta:
    function inputNumber(digit) {
      if (shouldResetDisplay) {
        display.textContent = digit;
        shouldResetDisplay = false;
      } else {
        if (display.textContent === '0' && digit !== '.') {
          display.textContent = digit;
        } else {
          if (display.textContent.length < 12) {
            display.textContent += digit;
          }
        }
      }
    }
  3. Implemente a função inputOperator que gerencia o armazenamento do primeiro operando e do operador, calculando resultados parciais quando necessário. Resposta:

    ✓ Resposta:
    function inputOperator(op) {
      if (display.textContent === 'Erro') {
        clearCalculator();
      }
      if (operator && !shouldResetDisplay) {
        const result = calculate(previousValue, display.textContent, operator);
        display.textContent = result;
        previousValue = result;
      } else {
        previousValue = display.textContent;
      }
      operator = op;
      shouldResetDisplay = true;
    }
  4. Adicione um evento de teclado que permita usar números, operadores, Enter para igual, Escape para limpar e vírgula para decimal. Resposta:

    ✓ Resposta:
    document.addEventListener('keydown', (e) => {
      if (e.key >= '0' && e.key <= '9') inputNumber(e.key);
      if (['+', '-', '*', '/'].includes(e.key)) inputOperator(e.key);
      if (e.key === 'Enter' || e.key === '=') calculateResult();
      if (e.key === 'Escape' || e.key.toLowerCase() === 'c') clearCalculator();
      if (e.key === '.') inputDecimal();
      if (e.key === 'Backspace') deleteLast();
    });
  5. Explique como você lidaria com o caso de o usuário pressionar o botão de igual sem ter digitado um segundo número ou sem operador. Resposta:

    ✓ Resposta: Na função calculateResult, se não houver operador (operator === null) ou se o display deve ser resetado (shouldResetDisplay), a função retorna sem fazer nada, mantendo o display inalterado. Isso evita que o usuário veja resultados inesperados.