learning-front

Nivel 2 · JavaScript: fundamentos del lenguaje

El DOM y los eventos

Seleccionar, modificar y reaccionar: querySelector, mutaciones y listeners.

En el Nivel 0 viste el DOM de lejos: una representación en árbol de la página que el navegador construye al leer el HTML. En el Nivel 1 escribiste HTML a mano. Ahora llega el paso que conecta ambas cosas: manipular ese árbol desde JavaScript.

Esto es lo que hace React (y cualquier framework) por debajo. Entenderlo bien aquí es lo que te permite, más adelante, razonar sobre rendimiento, problemas de sincronización y decisiones de arquitectura en lugar de seguir recetas de memoria.

Seguimos con el Overwatch Team Builder: esta vez pintamos la plantilla de héroes desde JS y la hacemos interactiva, sin frameworks.

El DOM como API#

Cuando el navegador termina de leer el HTML, construye un árbol de objetos en memoria: el Document Object Model. Cada etiqueta se convierte en un nodo; cada nodo tiene propiedades y métodos que podemos leer y modificar desde JS. Lo que llamas document en el navegador es la raíz de ese árbol.

Lo crucial es que el DOM es vivo: si modificas un nodo, la pantalla se actualiza al instante. No hace falta recargar la página.

Seleccionar nodos#

Para tocar un elemento primero hay que encontrarlo. Los métodos que más usarás:

javascript
// coge del DOM el nodo con id "plantilla"
const contenedor = document.querySelector('#plantilla');
// coge el primer botón que tenga ambas clases
const boton = document.querySelector('.filtro.activo');

// Varios elementos (todos los que coincidan): devuelve un NodeList
// coge TODOS los nodos con clase "hero-card"
const tarjetas = document.querySelectorAll('.hero-card');
// ⚠ NodeList NO es un array: tiene .forEach(), pero NO tiene .map(), .filter() ni .reduce().
// Si necesitas esos métodos (que acabas de aprender), conviértelo primero:
// spread: convierte el NodeList en un array de verdad
const arr = [...tarjetas];
// ahora sí puedes usar map/filter/reduce
const nombres = arr.map(t => t.querySelector('.hero-nombre').textContent);

// Por id (atajo histórico, aún muy usado)
// equivale a querySelector('#app'), sin el #
const app = document.getElementById('app');

querySelector y querySelectorAll aceptan cualquier selector CSS que ya conoces: de clase, de id, combinados, anidados. Si el elemento no existe, querySelector devuelve null y el código revienta si intentas usar ese null: comprueba siempre antes de operar. El patrón es un simple if con el nodo (un nodo es truthy; null es falsy, como viste con los valores):

javascript
// querySelector devuelve el nodo, o null si no encuentra nada en la página.
const cabecera = document.querySelector('h1');

// Comprueba ANTES de tocarlo: si fuera null, este if te ahorra el error.
if (cabecera) {
  // solo entras aquí si el nodo existe de verdad
  console.log('La cabecera existe: ya puedo trabajar con ella');
} else {
  // rama para cuando no está: avisas en vez de reventar
  console.log('No hay ningún h1 en la página');
}

Leer y modificar#

Una vez tienes el nodo, puedes:

Texto y contenido:

javascript
// cogemos del DOM el nodo de la etiqueta h1
const h1 = document.querySelector('h1');

// Leer o escribir el texto visible (sin etiquetas)
// sobreescribe el texto del h1; nunca parsea HTML
h1.textContent = 'Overwatch Team Builder';

// innerHTML parsea el string como HTML → útil para contenido controlado,
// pero NUNCA con datos del usuario (riesgo de inyección de código, XSS).
// sustituye TODO el contenido interno del nodo por el HTML que le pasas
contenedor.innerHTML = '<p>Hola</p>';

La regla práctica: si el valor viene del usuario (input, URL, API externa), usa textContent. Si es HTML que escribes tú mismo y controlas, innerHTML ahorra trabajo.

Clases:

javascript
// añade la clase "seleccionado" sin pisar las demás que ya tenga
tarjeta.classList.add('seleccionado');
// quita la clase "seleccionado" si existe; no hace nada si no estaba
tarjeta.classList.remove('seleccionado');
// añade si no está, quita si está — perfecto para estados que alternan
tarjeta.classList.toggle('seleccionado');
// segundo argumento: si es true fuerza añadir la clase aunque ya estuviera
tarjeta.classList.toggle('seleccionado', true);
// si es false fuerza quitarla aunque no estuviera — útil en filtros
tarjeta.classList.toggle('seleccionado', false);
// devuelve true si el nodo tiene esa clase, false si no
tarjeta.classList.contains('seleccionado');

classList.toggle es el patrón para estados que alternan: modo oscuro, elemento activo, acordeón abierto/cerrado. Con el segundo argumento booleano puedes forzar el resultado sin importar el estado previo: lo verás en el playground al marcar el botón activo del filtro con b.classList.toggle('activo', b === boton).

Atributos y estilos:

javascript
// escribe o sobreescribe un atributo cualquiera en el nodo
boton.setAttribute('aria-pressed', 'true');
// lee el valor de ese atributo (devuelve string o null si no existe)
boton.getAttribute('aria-pressed');
// Atributos data-*: para guardar TUS propios datos en el HTML.
// escribe data-rol="Tanque" en el nodo (dataset.rol ↔ atributo data-rol)
boton.dataset.rol = 'Tanque';
// y se lee de vuelta igual de fácil → 'Tanque'
const rolDelBoton = boton.dataset.rol;
// acceso directo como propiedad: equivale a setAttribute('src', ...)
imagen.src = '/heroes/tracer.png';
// aplica un estilo inline al nodo (preferible manipular clases con classList)
nodo.style.display = 'none';

En general manipular clases es mejor que manipular style directamente: las clases mantienen los estilos en el CSS, donde son más fáciles de mantener y de reutilizar.

Un apunte sobre los atributos data-*: son atributos que te inventas tú —data-rol, data-id, data-estado— para colgar datos a medida en un elemento del HTML. Se leen y escriben con la propiedad dataset, que toma el nombre del atributo, le quita el data- y te lo entrega como propiedad: data-rol se convierte en dataset.rol (si el atributo lleva guiones, como data-hero-id, une las palabras: dataset.heroId). Es el sitio estándar para guardar, por ejemplo, el rol al que filtra un botón y recuperarlo cuando el usuario hace clic. Lo verás repetido por todo este capítulo.

Crear y montar nodos#

En lugar de pisar el HTML con innerHTML, puedes construir nodos de forma programática:

javascript
function crearTarjetaHeroe(heroe) {
  // crea un nodo <article> nuevo, todavía fuera del DOM
  const article = document.createElement('article');
  // asigna la clase CSS que lo estilará
  article.className = 'hero-card';
  // escribe el atributo data-rol; en el HTML queda data-rol="Tanque"
  article.dataset.rol = heroe.rol;

  // crea un <p> hijo para el nombre
  const nombre = document.createElement('p');
  nombre.className = 'hero-nombre';
  // asigna el texto; seguro aunque contenga < o >
  nombre.textContent = heroe.nombre;

  // inserta el <p> dentro del <article>
  article.append(nombre);
  // devuelve el nodo construido, listo para montar
  return article;
}

// Montarlo en el DOM
// cogemos del DOM el nodo con id "plantilla"
const contenedor = document.querySelector('#plantilla');
// inserta el <article> al final del contenedor
contenedor.append(crearTarjetaHeroe(tracer));

append añade al final del padre. Si quieres insertar en una posición concreta, tienes prepend, before y after:

javascript
// cogemos del DOM el <ul> contenedor de tarjetas
const lista = document.querySelector('#plantilla');
// creamos un <li> nuevo para Tracer
const nueva = document.createElement('li');
nueva.className = 'hero-card';
// le ponemos el texto visible
nueva.textContent = 'Tracer';

// inserta nueva como PRIMER hijo de la lista (antes de todas las tarjetas)
lista.prepend(nueva);

// before y after insertan el nodo como HERMANO, fuera del padre, no como hijo:
// cogemos la primera tarjeta del DOM
const primera = lista.querySelector('.hero-card');
// creamos otro <li>
const extra = document.createElement('li');
extra.className = 'hero-card';
extra.textContent = 'Encabezado';

// coloca extra justo ANTES de primera, al mismo nivel en el árbol
primera.before(extra);
// primera.after(extra); // alternativa: lo colocaría justo DESPUÉS de primera

Para insertar muchos elementos a la vez sin un repintado por cada uno, usa un DocumentFragment: es un contenedor temporal fuera del DOM activo que se monta en una sola operación:

javascript
// contenedor temporal fuera del DOM; no provoca repintados
const fragmento = document.createDocumentFragment();
// acumulamos todos los nodos en el fragmento
heroes.forEach((h) => fragmento.append(crearTarjetaHeroe(h)));
// volcamos el fragmento al DOM en una sola operación: un solo repintado
contenedor.append(fragmento);

Eventos#

Un evento es algo que ocurre en la página: un clic, una pulsación de tecla, el formulario que se envía, el scroll que avanza. Puedes reaccionar a cualquiera de ellos con addEventListener:

javascript
// cogemos del DOM el nodo con id "btn-filtrar"
const boton = document.querySelector('#btn-filtrar');

// registramos un handler: se ejecutará cada vez que se haga clic
boton.addEventListener('click', function (event) {
  // event es el objeto con información sobre el evento
  // event.target es el nodo concreto que recibió el clic (puede ser un hijo del listener)
  // leemos el texto del nodo pulsado
  console.log('Clic en ' + event.target.textContent);
});

El segundo argumento es la función que se ejecutará: el handler o manejador. Puede ser una función anónima como arriba, una arrow function, o una función con nombre. El parámetro event (o e, como se abrevia) lleva información sobre lo que ocurrió: event.target es el elemento concreto que lo disparó y event.type es el tipo de evento. Según el tipo tendrás propiedades adicionales.

Quitar un listener con removeEventListener

addEventListener registra el handler pero no lo borra solo. Si en algún momento necesitas dejar de escuchar un evento (por ejemplo, al destruir un componente o al finalizar una animación), usas removeEventListener con la misma referencia a la función:

javascript
// necesitamos guardar la función en una variable para poder quitarla después
function manejarClic(e) {
  // lógica que se ejecuta al hacer clic
  console.log('Clic en ' + e.target.textContent);
}

// registramos el handler con la referencia guardada
boton.addEventListener('click', manejarClic);

// más tarde, cuando ya no queremos escuchar el evento:
// removeEventListener necesita la MISMA referencia; una función nueva no funciona
boton.removeEventListener('click', manejarClic);

La razón más habitual para necesitar removeEventListener es cuando añades listeners dentro de una función que se llama varias veces: cada llamada registra un handler nuevo y el nodo acaba con handlers duplicados que se disparan a la vez. La solución habitual en este capítulo es la delegación: un solo listener en el contenedor padre, nunca uno por hijo, lo que elimina el problema de raíz.

Aquí están los eventos más usados, cada uno con su ejemplo:

input se dispara en cada carácter que el usuario escribe. event.target.value es lo que hay en el campo en ese momento:

javascript
// cogemos el <input> del DOM
const campoBusqueda = document.querySelector('#busqueda');

// se dispara con CADA pulsación de tecla
campoBusqueda.addEventListener('input', (e) => {
  // e.target es el input; .value es su contenido actual
  const texto = e.target.value;
  // por ejemplo: "Buscando: Trac"
  console.log('Buscando: ' + texto);
});

keydown se dispara al pulsar cualquier tecla. event.key da el nombre de la tecla pulsada:

javascript
// escuchamos en el documento entero
document.addEventListener('keydown', (e) => {
  // e.key es el nombre de la tecla: "Escape", "Enter", "a", "1"...
  if (e.key === 'Escape') {
    console.log('Se pulsó Escape');
  }
});

submit se dispara al enviar un formulario. Sin event.preventDefault(), el navegador recarga la página (comportamiento por defecto de los formularios HTML):

javascript
// cogemos el <form> del DOM
const formulario = document.querySelector('#form-heroe');

formulario.addEventListener('submit', (e) => {
  // cancelamos el comportamiento por defecto: la página NO se recarga
  e.preventDefault();
  // leemos el valor del input de nombre
  const nombre = document.querySelector('#nombre').value;
  console.log('Héroe enviado: ' + nombre);
});

Para eventos de ratón como click o mousemove, event.clientX y event.clientY dan la posición del cursor en píxeles dentro de la ventana:

javascript
// se dispara cada vez que el ratón se mueve
document.addEventListener('mousemove', (e) => {
  // posición en px desde la esquina superior izquierda
  console.log('X: ' + e.clientX + ' Y: ' + e.clientY);
});

Los eventos más usados en el día a día: click, input, change (al salir del campo con un valor distinto al que tenía al entrar), submit, keydown, scroll.

¿Por qué no onclick en el HTML? Porque mezcla comportamiento y estructura en el mismo fichero, solo admite un handler por evento, y complica los tests. Con addEventListener el JS está donde debe estar: en el .js.

Subir por el árbol con .closest()#

Antes de ver la delegación, hay un método del DOM que la hace posible: .closest(selector). Dado un nodo, sube por sus ancestros (incluyendo el propio nodo) y devuelve el primero que casa con el selector CSS que le pases. Si no encuentra ninguno, devuelve null.

javascript
// Supón esta estructura HTML:
// <ul id="lista">
//   <li class="hero-card">
//     <p class="hero-nombre">Tracer</p>
//   </li>
// </ul>

// cogemos el <p> directamente (el nodo más profundo)
const parrafo = document.querySelector('.hero-nombre');

// sube desde el <p> hasta el primer ancestro (o él mismo) con clase "hero-card"
const tarjeta = parrafo.closest('.hero-card');
// tarjeta es el <li class="hero-card"> → encontró el primer ancestro que casa
console.log('Tarjeta encontrada: ' + tarjeta.className);

// si no hay ningún ancestro que case, devuelve null
const inexistente = parrafo.closest('.no-existe');
// inexistente es null → hay que comprobarlo antes de usarlo
console.log('No existe: ' + inexistente);

Esto es fundamental en la delegación de eventos: cuando el usuario hace clic en el <p> del nombre, event.target apunta al <p> (el nodo más profundo que recibió el clic), no a la tarjeta. .closest('.hero-card') sube desde ese <p> hasta encontrar la tarjeta, sin importar en qué hijo interior cayó exactamente el clic.

Delegación de eventos#

Imagina que tienes una lista con cien tarjetas de héroe y quieres que cada una reaccione a un clic. Una opción es añadir un listener a cada tarjeta: cien listeners en memoria.

La opción inteligente es aprovechar el bubbling: cuando haces clic en un elemento, el evento sube por el árbol —burbujea— hasta llegar al documento. Puedes capturarlo en cualquier ancestro:

javascript
// cogemos del DOM el nodo con id "plantilla"
const lista = document.querySelector('#plantilla');

// un solo listener en el padre; el evento burbujea desde los hijos
lista.addEventListener('click', (e) => {
  // e.target es el nodo más profundo que recibió el clic (puede ser un <p> dentro de la tarjeta)
  // closest sube por los ancestros buscando el primero que coincide con el selector; devuelve null si no encuentra
  const tarjeta = e.target.closest('.hero-card');
  // el clic no cayó sobre ninguna tarjeta (por ejemplo, en el hueco entre tarjetas)
  if (!tarjeta) return;

  // alterna la clase: la añade si no estaba, la quita si estaba
  tarjeta.classList.toggle('seleccionado');
});

Aquí conviene separar dos propiedades del evento que se confunden a todas horas:

  • event.currentTarget es siempre el nodo donde registraste el listener (aquí, lista, el padre).
  • event.target es el nodo exacto sobre el que ocurrió el clic, que puede ser un hijo muy profundo (el <p> del nombre).

En delegación trabajas con target (dónde cayó el clic de verdad) y subes con .closest() hasta el elemento que te interesa; currentTarget siempre apunta al padre que escucha, pase lo que pase.

Un solo listener maneja todas las tarjetas, las que están ahora y las que añadas después. Es el patrón que están bajo el capó de cualquier framework: React no pone un listener por componente, sino uno global en la raíz del documento.

Accesibilidad con ARIA#

El HTML semántico (<button>, <a>, <input>) ya comunica su propósito a los lectores de pantalla. Pero a veces construyes elementos interactivos con etiquetas genéricas —<div>, <article>— que no tienen semántica de interacción por defecto. Ahí entra ARIA (Accessible Rich Internet Applications): un conjunto de atributos HTML que añaden significado para las tecnologías de asistencia sin cambiar la apariencia visual.

Los tres atributos que aparecen en el tier excelente del ejercicio:

  • role: declara qué tipo de widget es el elemento. role="checkbox" le dice al lector de pantalla “este nodo es una casilla que puede estar marcada o no”. Sin él, un <article> es simplemente “región” y no se anuncia como algo activable.

  • aria-checked: el estado actual de ese checkbox. "true" = seleccionado, "false" = no seleccionado. Debe mantenerse sincronizado con lo que muestra el CSS: si añades la clase seleccionado, también actualizas aria-checked a "true".

  • aria-live: indica a los lectores de pantalla que una zona de la página puede cambiar sin que el foco se mueva a ella. aria-live="polite" anuncia el cambio cuando el usuario termina lo que está haciendo (sin interrumpir); "assertive" interrumpe al instante (para errores urgentes).

javascript
// creamos un <article> que actuará como checkbox clicable
const tarjeta = document.createElement('article');

// role: anuncia al lector de pantalla qué tipo de widget es
tarjeta.setAttribute('role', 'checkbox');
// aria-checked: estado inicial no seleccionado
tarjeta.setAttribute('aria-checked', 'false');
// tabindex 0: hace el nodo enfocable con Tab (navegación por teclado)
tarjeta.setAttribute('tabindex', '0');

// al hacer clic, alternamos la clase visual Y sincronizamos el atributo ARIA
tarjeta.addEventListener('click', () => {
  // classList.toggle devuelve true si se añadió, false si se quitó
  const ahora = tarjeta.classList.toggle('seleccionado');
  // sincronizamos el estado ARIA con el visual
  tarjeta.setAttribute('aria-checked', String(ahora));
});

La regla de oro: el estado visible y el estado ARIA deben moverse siempre juntos. Si el CSS dice “seleccionado” pero aria-checked sigue en "false", el lector de pantalla anuncia algo diferente a lo que ve el usuario con visión.

aria-live se usa de otra forma: con una zona de estado, un nodo que el lector de pantalla vigila y anuncia cuando su texto cambia, sin mover el foco hasta él. Es lo ideal para confirmar acciones —“Mercy añadida”, “3 héroes en la plantilla”— a quien navega sin ver la pantalla:

javascript
// Una zona viva: el lector de pantalla anunciará sus cambios de texto.
const aviso = document.createElement('p');
// polite = espera a que el usuario haga una pausa, no interrumpe lo que esté oyendo.
aviso.setAttribute('aria-live', 'polite');
// (suele ocultarse a la vista con CSS, pero el lector de pantalla sí la lee)

function anunciar(mensaje) {
  // basta con cambiar el texto: aria-live se encarga de que se anuncie
  aviso.textContent = mensaje;
}

// Por ejemplo, justo después de seleccionar a un héroe:
anunciar('Mercy añadida a la plantilla');

Pruébalo#

El playground pinta la plantilla completa y conecta filtros y selección. Observa cómo la delegación permite que el filtro funcione con un solo listener en el <nav> y que la selección funcione con uno en <ul>, sea cual sea el número de héroes. Edita el código y experimenta.

Comprueba lo que sabes#

Pregunta 1 de 5

Tienes este HTML: `<p id="estado">Cargando...</p>`. ¿Cómo cambias su texto a "Listo" con JS?

Tu turno#

La página ya tiene su HTML y sus estilos. Tu tarea es escribir index.js: pintar las tarjetas, conectar los filtros y hacer seleccionables las tarjetas. Cuando lo tengas, despliega las soluciones y fíjate en el salto de un nivel al siguiente.

Ejercicio · en esta página

Pinta y conecta la plantilla de héroes

El HTML de la página ya está: un contenedor vacío y cuatro botones de filtro. Tu trabajo en index.js es pintarla y hacerla interactiva: crear una tarjeta por héroe, reaccionar al clic en los botones de filtro y permitir seleccionar tarjetas con un clic.

Paso 1: Que funcione

  • Se ven las ocho tarjetas al cargar la página.
  • Los botones filtran correctamente y el botón activo se marca con la clase "activo".
  • Hacer clic en una tarjeta alterna la clase "seleccionado" (el efecto visual aparece).
Ver soluciones
const heroes = [
  { nombre: "Tracer", rol: "Daño", partidas: 120, victorias: 78 },
  { nombre: "Reinhardt", rol: "Tanque", partidas: 90, victorias: 51 },
  { nombre: "Mercy", rol: "Apoyo", partidas: 200, victorias: 130 },
  { nombre: "Genji", rol: "Daño", partidas: 150, victorias: 72 },
  { nombre: "Ana", rol: "Apoyo", partidas: 110, victorias: 66 },
  { nombre: "Winston", rol: "Tanque", partidas: 80, victorias: 38 },
  { nombre: "Lucio", rol: "Apoyo", partidas: 95, victorias: 58 },
  { nombre: "Pharah", rol: "Daño", partidas: 70, victorias: 39 },
];

// Pintamos todo el contenedor de golpe con innerHTML.
// En cada clic de filtro, borramos todo y volvemos a construir el HTML.

function winrate(h) {
  // Calculamos el porcentaje de victorias sobre partidas jugadas
  // toFixed(1) devuelve string con un decimal
  return ((h.victorias / h.partidas) * 100).toFixed(1);
}

function pintarHeroes(lista) {
  // cogemos del DOM el nodo con id "plantilla"
  const contenedor = document.getElementById("plantilla");
  if (!lista.length) {
    // Si no hay héroes para este filtro, mostramos un mensaje vacío
    contenedor.innerHTML =
      '<p class="vacio">No hay héroes para este filtro.</p>';
    return;
  }
  let html = "";
  for (let i = 0; i < lista.length; i++) {
    const h = lista[i];
    // Concatenamos el HTML de cada tarjeta como string: rápido pero peligroso con datos externos
    html += '<div class="hero-card" data-rol="' + h.rol + '">';
    html += '<p class="hero-nombre">' + h.nombre + "</p>";
    html += '<p class="hero-rol">' + h.rol + "</p>";
    html += '<div class="hero-stats">';
    html +=
      '<div class="stat"><span class="stat-etiqueta">Partidas</span><span class="stat-valor">' +
      h.partidas +
      "</span></div>";
    html +=
      '<div class="stat"><span class="stat-etiqueta">Victorias</span><span class="stat-valor">' +
      h.victorias +
      "</span></div>";
    html +=
      '<div class="stat"><span class="stat-etiqueta">Winrate</span><span class="stat-valor">' +
      winrate(h) +
      "%</span></div>";
    html += "</div>";
    html += "</div>";
  }
  // sustituye TODO el contenido del contenedor por el HTML construido
  contenedor.innerHTML = html;

  // Clic en cada tarjeta: un listener por elemento.
  // ⚠ Patrón antiguo: onclick en bucle — cada tarjeta recibe su propio handler.
  // Si pintarHeroes se llama de nuevo (filtro), los nodos nuevos no tienen listener
  // hasta que se vuelve a llamar. La delegación (solución "mejor") evita este problema.
  // coge todos los .hero-card dentro del contenedor
  const tarjetas = contenedor.querySelectorAll(".hero-card");
  for (let i = 0; i < tarjetas.length; i++) {
    // ⚠ Patrón antiguo: un onclick por nodo, descartado al repintar.
    tarjetas[i].onclick = function () {
      // alterna la clase "seleccionado": añade si no estaba, quita si estaba
      this.classList.toggle("seleccionado");
    };
  }
}

// Filtros: un onclick inline por botón.
// ⚠ Patrón antiguo: onclick asigna el handler directamente como propiedad del nodo.
// Solo admite UN handler por evento; si asignas otro lo sobreescribe sin avisar.
// La solución "mejor" y "excelente" usan addEventListener, que no tiene ese límite.
// estado que guarda qué filtro está activo
let rolActual = "todos";

// coge del DOM todos los nodos con clase "filtro"
const botones = document.querySelectorAll(".filtro");
for (let i = 0; i < botones.length; i++) {
  // ⚠ Patrón antiguo: onclick reemplaza cualquier handler previo.
  botones[i].onclick = function () {
    // leemos el rol guardado en data-rol del botón pulsado
    rolActual = this.dataset.rol;

    // Marcar activo: quitamos "activo" a todos y se la ponemos solo al que se pulsó
    for (let j = 0; j < botones.length; j++) {
      // quita la clase "activo" del botón j
      botones[j].classList.remove("activo");
    }
    // añade "activo" al botón pulsado (this)
    this.classList.add("activo");

    // Volver a pintar TODO el DOM filtrado
    const filtrados =
      rolActual === "todos"
        ? heroes
        : // filtramos el array por rol
          heroes.filter(function (h) {
            return h.rol === rolActual;
          });
    // re-renderizamos con los datos filtrados
    pintarHeroes(filtrados);
  };
}

// pintamos la lista completa al arrancar
pintarHeroes(heroes);

Por qué este nivel

  • Funciona: pinta los héroes, el filtro responde y se puede seleccionar una tarjeta.
  • Construye el HTML con concatenación de strings e innerHTML: rápido de escribir, pero si los datos vienen del usuario cualquier valor con < o > puede romper la página o inyectar código.
  • Un listener por tarjeta: si la lista tiene cien héroes, hay cien handlers en memoria, y los que se pintan después de un filtro no tienen listener hasta que se repinta de nuevo.
  • La lógica de datos, render y eventos está mezclada en el mismo bloque: cuesta seguirla y más aún cambiarla.