Proyecto 1: Calculadora Interactiva Completa
Objetivos de aprendizajeAl finalizar este proyecto serás capaz de:
- Construir una calculadora funcional con interfaz visual profesional
- Aplicar manejo de estado, eventos y DOM manipulation en un proyecto real
- Implementar lógica matemática con manejo de errores (división por cero, overflow)
- Usar event delegation para manejar todos los botones eficientemente
- Agregar funcionalidades avanzadas como historial, teclado y temas
Descripción del proyecto
Vamos a construir una calculadora completa que funcione tanto con clicks como con el teclado. No es la típica calculadora básica de tutorial — esta tendrá funcionalidades reales.
Funcionalidades requeridas
- Operaciones básicas: suma, resta, multiplicación, división
- Operaciones adicionales: porcentaje, cambio de signo (+/-)
- Botón CE (Clear Entry) y C (Clear All)
- Punto decimal con validación (no permitir dos puntos)
- Soporte de teclado completo
- Historial de operaciones
- Animaciones en botones
- Diseño responsive
Paso 1: La estructura HTML
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Calculadora JavaScript</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="calculator-container">
<div class="calculator">
<!-- Display -->
<div class="display">
<div class="display-history" id="displayHistory"></div>
<div class="display-current" id="displayCurrent">0</div>
</div>
<!-- Botones -->
<div class="buttons" id="buttons">
<button class="btn btn-function" data-action="clear">C</button>
<button class="btn btn-function" data-action="clear-entry">CE</button>
<button class="btn btn-function" data-action="percent">%</button>
<button class="btn btn-operator" data-action="divide">/</button>
<button class="btn btn-number" data-number="7">7</button>
<button class="btn btn-number" data-number="8">8</button>
<button class="btn btn-number" data-number="9">9</button>
<button class="btn btn-operator" data-action="multiply">x</button>
<button class="btn btn-number" data-number="4">4</button>
<button class="btn btn-number" data-number="5">5</button>
<button class="btn btn-number" data-number="6">6</button>
<button class="btn btn-operator" data-action="subtract">-</button>
<button class="btn btn-number" data-number="1">1</button>
<button class="btn btn-number" data-number="2">2</button>
<button class="btn btn-number" data-number="3">3</button>
<button class="btn btn-operator" data-action="add">+</button>
<button class="btn btn-number" data-action="toggle-sign">+/-</button>
<button class="btn btn-number" data-number="0">0</button>
<button class="btn btn-number" data-action="decimal">.</button>
<button class="btn btn-equals" data-action="equals">=</button>
</div>
</div>
<!-- Historial -->
<div class="history" id="history">
<h3>Historial</h3>
<div class="history-list" id="historyList"></div>
<button class="btn-clear-history" id="clearHistory">Limpiar historial</button>
</div>
</div>
<script src="calculator.js"></script>
</body>
</html>
Paso 2: Estilos CSS
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: linear-gradient(135deg, #0f0c29, #302b63, #24243e);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
color: white;
}
.calculator-container {
display: flex;
gap: 20px;
align-items: flex-start;
}
.calculator {
background: rgba(255,255,255,0.05);
backdrop-filter: blur(20px);
border-radius: 24px;
padding: 24px;
width: 320px;
box-shadow: 0 25px 50px rgba(0,0,0,0.4);
border: 1px solid rgba(255,255,255,0.1);
}
.display {
background: rgba(0,0,0,0.3);
border-radius: 16px;
padding: 20px;
margin-bottom: 20px;
min-height: 100px;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: flex-end;
}
.display-history {
font-size: 0.9rem;
color: rgba(255,255,255,0.5);
min-height: 24px;
word-break: break-all;
}
.display-current {
font-size: 2.5rem;
font-weight: 300;
word-break: break-all;
max-width: 100%;
}
.buttons {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.btn {
padding: 18px;
border: none;
border-radius: 12px;
font-size: 1.2rem;
cursor: pointer;
transition: all 0.15s ease;
font-weight: 500;
}
.btn:active { transform: scale(0.95); }
.btn-number {
background: rgba(255,255,255,0.1);
color: white;
}
.btn-number:hover { background: rgba(255,255,255,0.2); }
.btn-function {
background: rgba(255,255,255,0.15);
color: #a5b4fc;
}
.btn-function:hover { background: rgba(255,255,255,0.25); }
.btn-operator {
background: rgba(99, 102, 241, 0.3);
color: #818cf8;
}
.btn-operator:hover { background: rgba(99, 102, 241, 0.5); }
.btn-equals {
background: #6366f1;
color: white;
}
.btn-equals:hover { background: #4f46e5; }
.history {
background: rgba(255,255,255,0.05);
backdrop-filter: blur(20px);
border-radius: 24px;
padding: 20px;
width: 250px;
max-height: 500px;
overflow-y: auto;
border: 1px solid rgba(255,255,255,0.1);
}
.history h3 { margin-bottom: 12px; color: #a5b4fc; }
.history-item {
padding: 8px 0;
border-bottom: 1px solid rgba(255,255,255,0.05);
font-size: 0.85rem;
color: rgba(255,255,255,0.7);
cursor: pointer;
}
.history-item:hover { color: white; }
.history-item .result { color: #6366f1; font-weight: bold; }
.btn-clear-history {
margin-top: 12px;
width: 100%;
padding: 8px;
background: rgba(239, 68, 68, 0.2);
color: #fca5a5;
border: none;
border-radius: 8px;
cursor: pointer;
}
@media (max-width: 700px) {
.calculator-container { flex-direction: column; align-items: center; }
.history { width: 320px; }
}
Paso 3: La lógica JavaScript
// ============================================
// CALCULADORA INTERACTIVA
// Proyecto del Módulo 5: Proyectos Prácticos
// ============================================
// Estado de la calculadora
const estado = {
displayActual: "0",
displayHistorial: "",
primerOperando: null,
operador: null,
esperandoSegundoOperando: false,
historial: []
};
// Elementos del DOM
const displayCurrent = document.getElementById("displayCurrent");
const displayHistory = document.getElementById("displayHistory");
const buttonsContainer = document.getElementById("buttons");
const historyList = document.getElementById("historyList");
const clearHistoryBtn = document.getElementById("clearHistory");
// Operaciones
const operaciones = {
add: (a, b) => a + b,
subtract: (a, b) => a - b,
multiply: (a, b) => a * b,
divide: (a, b) => {
if (b === 0) throw new Error("No se puede dividir por cero");
return a / b;
}
};
const simbolos = {
add: "+",
subtract: "-",
multiply: "x",
divide: "/"
};
// Funciones principales
function ingresarNumero(numero) {
if (estado.esperandoSegundoOperando) {
estado.displayActual = numero;
estado.esperandoSegundoOperando = false;
} else {
// No permitir ceros al inicio (excepto "0.")
if (estado.displayActual === "0" && numero !== ".") {
estado.displayActual = numero;
} else {
// Limitar longitud
if (estado.displayActual.length >= 15) return;
estado.displayActual += numero;
}
}
actualizarDisplay();
}
function ingresarDecimal() {
if (estado.esperandoSegundoOperando) {
estado.displayActual = "0.";
estado.esperandoSegundoOperando = false;
actualizarDisplay();
return;
}
// No permitir dos puntos decimales
if (estado.displayActual.includes(".")) return;
estado.displayActual += ".";
actualizarDisplay();
}
function seleccionarOperador(operador) {
const valorActual = parseFloat(estado.displayActual);
if (estado.primerOperando !== null && !estado.esperandoSegundoOperando) {
// Ya hay un operador pendiente — calcular primero
try {
const resultado = operaciones[estado.operador](estado.primerOperando, valorActual);
estado.displayActual = formatearNumero(resultado);
estado.primerOperando = resultado;
} catch (error) {
estado.displayActual = "Error";
estado.primerOperando = null;
estado.operador = null;
estado.esperandoSegundoOperando = false;
actualizarDisplay();
return;
}
} else {
estado.primerOperando = valorActual;
}
estado.operador = operador;
estado.esperandoSegundoOperando = true;
estado.displayHistorial = `${formatearNumero(estado.primerOperando)} ${simbolos[operador]}`;
actualizarDisplay();
}
function calcular() {
if (estado.operador === null || estado.primerOperando === null) return;
const segundoOperando = parseFloat(estado.displayActual);
const expresion = `${formatearNumero(estado.primerOperando)} ${simbolos[estado.operador]} ${formatearNumero(segundoOperando)}`;
try {
const resultado = operaciones[estado.operador](estado.primerOperando, segundoOperando);
const resultadoFormateado = formatearNumero(resultado);
// Guardar en historial
agregarAlHistorial(expresion, resultadoFormateado);
estado.displayActual = resultadoFormateado;
estado.displayHistorial = `${expresion} =`;
estado.primerOperando = resultado;
estado.operador = null;
estado.esperandoSegundoOperando = true;
} catch (error) {
estado.displayActual = "Error";
estado.displayHistorial = expresion;
resetearEstado();
}
actualizarDisplay();
}
function porcentaje() {
const valor = parseFloat(estado.displayActual);
if (estado.primerOperando !== null) {
// X% de primerOperando
estado.displayActual = formatearNumero(estado.primerOperando * (valor / 100));
} else {
estado.displayActual = formatearNumero(valor / 100);
}
actualizarDisplay();
}
function cambiarSigno() {
const valor = parseFloat(estado.displayActual);
estado.displayActual = formatearNumero(valor * -1);
actualizarDisplay();
}
function limpiarTodo() {
estado.displayActual = "0";
estado.displayHistorial = "";
estado.primerOperando = null;
estado.operador = null;
estado.esperandoSegundoOperando = false;
actualizarDisplay();
}
function limpiarEntrada() {
estado.displayActual = "0";
actualizarDisplay();
}
// Funciones auxiliares
function formatearNumero(num) {
if (typeof num !== "number" || !isFinite(num)) return "Error";
// Limitar decimales y quitar ceros innecesarios
const resultado = parseFloat(num.toPrecision(12));
return resultado.toString();
}
function actualizarDisplay() {
displayCurrent.textContent = estado.displayActual;
displayHistory.textContent = estado.displayHistorial;
// Ajustar tamaño de fuente si el número es largo
const longitud = estado.displayActual.length;
if (longitud > 12) {
displayCurrent.style.fontSize = "1.5rem";
} else if (longitud > 9) {
displayCurrent.style.fontSize = "2rem";
} else {
displayCurrent.style.fontSize = "2.5rem";
}
}
function resetearEstado() {
estado.primerOperando = null;
estado.operador = null;
estado.esperandoSegundoOperando = false;
}
function agregarAlHistorial(expresion, resultado) {
estado.historial.unshift({ expresion, resultado, fecha: new Date() });
if (estado.historial.length > 20) estado.historial.pop();
renderizarHistorial();
}
function renderizarHistorial() {
historyList.innerHTML = estado.historial.map(item => `
<div class="history-item" data-resultado="${item.resultado}">
${item.expresion} = <span class="result">${item.resultado}</span>
</div>
`).join("");
}
// Event Delegation para botones
buttonsContainer.addEventListener("click", (e) => {
const btn = e.target.closest(".btn");
if (!btn) return;
// Animación de click
btn.style.transform = "scale(0.95)";
setTimeout(() => btn.style.transform = "", 100);
const numero = btn.dataset.number;
const accion = btn.dataset.action;
if (numero !== undefined) {
ingresarNumero(numero);
return;
}
switch (accion) {
case "add":
case "subtract":
case "multiply":
case "divide":
seleccionarOperador(accion);
break;
case "equals": calcular(); break;
case "clear": limpiarTodo(); break;
case "clear-entry": limpiarEntrada(); break;
case "decimal": ingresarDecimal(); break;
case "percent": porcentaje(); break;
case "toggle-sign": cambiarSigno(); break;
}
});
// Soporte de teclado
const teclaToAccion = {
"0": () => ingresarNumero("0"),
"1": () => ingresarNumero("1"),
"2": () => ingresarNumero("2"),
"3": () => ingresarNumero("3"),
"4": () => ingresarNumero("4"),
"5": () => ingresarNumero("5"),
"6": () => ingresarNumero("6"),
"7": () => ingresarNumero("7"),
"8": () => ingresarNumero("8"),
"9": () => ingresarNumero("9"),
".": () => ingresarDecimal(),
"+": () => seleccionarOperador("add"),
"-": () => seleccionarOperador("subtract"),
"*": () => seleccionarOperador("multiply"),
"/": () => seleccionarOperador("divide"),
"Enter": () => calcular(),
"=": () => calcular(),
"Escape": () => limpiarTodo(),
"Backspace": () => {
if (estado.displayActual.length > 1) {
estado.displayActual = estado.displayActual.slice(0, -1);
} else {
estado.displayActual = "0";
}
actualizarDisplay();
},
"%": () => porcentaje(),
};
document.addEventListener("keydown", (e) => {
const accion = teclaToAccion[e.key];
if (accion) {
e.preventDefault();
accion();
}
});
// Historial: click para reutilizar resultado
historyList.addEventListener("click", (e) => {
const item = e.target.closest(".history-item");
if (!item) return;
estado.displayActual = item.dataset.resultado;
actualizarDisplay();
});
clearHistoryBtn.addEventListener("click", () => {
estado.historial = [];
renderizarHistorial();
});
console.log("Calculadora cargada. Usá el teclado o los botones.");
Conceptos aplicados
Este proyecto utiliza:
| Concepto | Dónde se usa |
|---|---|
| Variables y constantes | Estado de la calculadora, configuración |
| Objetos | Estado, operaciones, símbolos, mapeo de teclas |
| Funciones | Cada operación es una función pura |
| Condicionales | Validaciones (decimal, overflow, división por cero) |
| Switch | Mapeo de acciones de botones |
| DOM manipulation | Actualizar display, renderizar historial |
| Event delegation | Un solo listener para todos los botones |
| Eventos de teclado | Soporte completo de teclado |
| Closures | Estado encapsulado en el módulo |
| Template literals | Renderizado dinámico del historial |
| Error handling | try/catch para división por cero |
| Arrays | Historial de operaciones |
Desafíos extra
- Memoria (M+, M-, MR, MC): Agregá botones de memoria
- Modo científico: raíz cuadrada, potencia, seno, coseno
- Temas (claro/oscuro): Toggle entre temas con localStorage
- Historial persistente: Guardá el historial en localStorage
- Animaciones: Transiciones CSS suaves en el display
- PWA: Convertilo en una Progressive Web App instalable
Revisemos los puntos más importantes de esta lección antes de continuar.
Quiz de autoevaluación
1. ¿Por qué usamos event delegation en los botones?
a) Porque es más lento pero más seguro
b) Para tener un solo listener en vez de 20, y que funcione con elementos dinámicos
c) Porque addEventListener no funciona en botones
d) Es obligatorio en JavaScript
2. ¿Por qué necesitamos el estado esperandoSegundoOperando?
a) Para animaciones
b) Para saber si el siguiente número debe reemplazar o concatenarse al display
c) Para manejar errores
d) No es necesario
3. ¿Qué pasa si no manejamos la división por cero?
a) JavaScript devuelve 0
b) JavaScript devuelve Infinity (no error)
c) El navegador se cierra
d) Da un error de sintaxis
Respuestas: 1-b, 2-b, 3-b
- Comprendo el concepto principal y puedo explicarlo con mis palabras
- Entiendo cómo aplicarlo en mi situación específica
- Necesito repasar algunas partes antes de continuar
- Quiero ver más ejemplos prácticos del tema