Una tabla de notas con el DOM: crear filas, ordenar con aria-sort y filtrar
Construye con JavaScript una tabla de notas a partir de datos JSON: filas creadas con createElement y textContent (sin innerHTML, para que los datos no se conviertan en HTML), un resumen que se actualiza, orden por columnas con aria-sort y un buscador que filtra sin distinguir mayúsculas ni tildes.
- El DOM: createElement, append y textContent
- Eventos: click e input
- Datos JSON en la página
- Ordenar con sort y localeCompare
- Accesibilidad: aria-sort y aria-live
- Normalizar texto (tildes)
Enunciado
El DOM es el árbol de objetos que el navegador construye a partir del HTML, y JavaScript puede crearlo, cambiarlo y reaccionar a lo que hace el usuario. Es la base de cualquier página interactiva, desde una tabla que se ordena hasta un framework como React, que por dentro hace exactamente esto.
La página ya tiene el buscador, el párrafo del resumen y la tabla vacía; los datos de los alumnos están en un <script type="application/json" id="datos">, como los dejaría el servidor. Uno de los nombres trae etiquetas HTML a propósito: tu código debe mostrarlas como texto. El ejercicio se hace en tres apartados que van ampliando el mismo script.
Qué hay que hacer
- Cada apartado se corrige por separado con pruebas que se ejecutan dentro de la página. Debajo del editor tienes el resultado en directo.
- Los apartados se encadenan: cada uno empieza con la solución del anterior. Conserva los
id, las clases y los textos: las pruebas los buscan.
Guía paso a paso
Intenta resolverlo por tu cuenta y abre un paso solo cuando te atasques: cada uno te acerca a la solución sin dártela entera.
1. Crear nodos
document.createElement('tr') crea el elemento, textContent le pone el texto y append lo cuelga del árbol. Es más largo que escribir HTML en una cadena, pero nunca interpreta los datos.
const td = document.createElement('td');
td.textContent = a.nombre;
tr.append(td);2. Datos y vista
Guarda los datos (el array de alumnos) aparte de la vista (las filas) y relaciónalos con un Map. Ordenar y filtrar son operaciones sobre los datos que después se reflejan en las filas.
3. Eventos
Un click en cada botón de la cabecera y un input en el buscador. Dentro del manejador, boton.dataset.campo lee el data-campo del botón pulsado.
4. Accesible desde el principio
Botones de verdad en la cabecera (se pueden usar con el teclado), aria-sort para el orden y aria-live en el resumen. Son atributos, no código extra.
Resuélvelo aquí
Cada apartado edita un fichero de la página (el HTML, el CSS o el JavaScript) y se corrige con pruebas que se ejecutan en la propia página, también a distintos anchos de pantalla. Debajo del editor ves el resultado en directo.
Lee los alumnos del JSON y crea una fila por alumno con tres celdas: el nombre, la nota con dos decimales y coma (7,50) y Aprobado (nota de 5 o más) o Suspenso; la fila lleva la clase aprobado o suspenso. Crea los elementos con createElement y pon los textos con textContent. Después escribe en #resumen N alumnos · media X · A aprobados (la media con dos decimales y coma; 1 aprobado en singular).
Ordena la tabla al pulsar los botones de la cabecera (data-campo dice el campo): el primer clic en una columna ordena de menor a mayor y el siguiente en la misma invierte el orden; cambiar de columna vuelve a ordenar de menor a mayor. Los nombres se comparan como en español (localeCompare con 'es': la Ó va con la O). Mueve las filas que ya existen (sin crear otras) y pon en el th de la columna aria-sort="ascending" o "descending", quitándoselo a los demás.
Filtra mientras se escribe en #filtro: se ocultan (con hidden) las filas cuyo nombre no contiene el texto, sin distinguir mayúsculas ni tildes (oscar encuentra a Óscar). El resumen cuenta solo las visibles: V de N alumnos · media X · A aprobados si hay texto en el buscador, como antes si está vacío, y Ningún alumno coincide con «texto» si no queda ninguna. Ordenar sigue funcionando con el filtro puesto.
Solución explicada
Ver las soluciones de todos los apartados
Apartado 1 · JavaScript
const alumnos = JSON.parse(document.getElementById('datos').textContent);
const cuerpo = document.querySelector('#notas tbody');
const resumen = document.getElementById('resumen');
const filtro = document.getElementById('filtro');
const coma = (x) => x.toFixed(2).replace('.', ',');
const datosDe = new Map(); // fila → su alumno
// 1. Las filas se crean con el DOM y textContent: los datos nunca se interpretan como HTML
const filas = alumnos.map((a) => {
const tr = document.createElement('tr');
tr.className = a.nota >= 5 ? 'aprobado' : 'suspenso';
for (const texto of [a.nombre, coma(a.nota), a.nota >= 5 ? 'Aprobado' : 'Suspenso']) {
const td = document.createElement('td');
td.textContent = texto;
tr.append(td);
}
datosDe.set(tr, a);
cuerpo.append(tr);
return tr;
});
function actualizarResumen() {
const visibles = filas.filter((tr) => !tr.hidden).map((tr) => datosDe.get(tr));
const texto = filtro.value.trim();
if (visibles.length === 0) {
resumen.textContent = `Ningún alumno coincide con «${texto}»`;
return;
}
const media = visibles.reduce((s, a) => s + a.nota, 0) / visibles.length;
const aprobados = visibles.filter((a) => a.nota >= 5).length;
resumen.textContent = `${texto ? visibles.length + ' de ' : ''}${alumnos.length} alumnos · media ${coma(media)} · ${aprobados} ${aprobados === 1 ? 'aprobado' : 'aprobados'}`;
}
actualizarResumen();Apartado 2 · JavaScript
const alumnos = JSON.parse(document.getElementById('datos').textContent);
const cuerpo = document.querySelector('#notas tbody');
const resumen = document.getElementById('resumen');
const filtro = document.getElementById('filtro');
const coma = (x) => x.toFixed(2).replace('.', ',');
const datosDe = new Map(); // fila → su alumno
// 1. Las filas se crean con el DOM y textContent: los datos nunca se interpretan como HTML
const filas = alumnos.map((a) => {
const tr = document.createElement('tr');
tr.className = a.nota >= 5 ? 'aprobado' : 'suspenso';
for (const texto of [a.nombre, coma(a.nota), a.nota >= 5 ? 'Aprobado' : 'Suspenso']) {
const td = document.createElement('td');
td.textContent = texto;
tr.append(td);
}
datosDe.set(tr, a);
cuerpo.append(tr);
return tr;
});
function actualizarResumen() {
const visibles = filas.filter((tr) => !tr.hidden).map((tr) => datosDe.get(tr));
const texto = filtro.value.trim();
if (visibles.length === 0) {
resumen.textContent = `Ningún alumno coincide con «${texto}»`;
return;
}
const media = visibles.reduce((s, a) => s + a.nota, 0) / visibles.length;
const aprobados = visibles.filter((a) => a.nota >= 5).length;
resumen.textContent = `${texto ? visibles.length + ' de ' : ''}${alumnos.length} alumnos · media ${coma(media)} · ${aprobados} ${aprobados === 1 ? 'aprobado' : 'aprobados'}`;
}
actualizarResumen();
// 2. Ordenar al pulsar la cabecera: mismo campo, se invierte; otro campo, ascendente
let orden = { campo: null, asc: true };
document.querySelectorAll('#notas th button').forEach((boton) => {
boton.addEventListener('click', () => {
const campo = boton.dataset.campo;
orden = { campo, asc: orden.campo === campo ? !orden.asc : true };
const comparar = campo === 'nota'
? (x, y) => x.nota - y.nota
: (x, y) => x.nombre.localeCompare(y.nombre, 'es');
filas.sort((f, g) => (orden.asc ? 1 : -1) * comparar(datosDe.get(f), datosDe.get(g)));
cuerpo.append(...filas); // append mueve los nodos que ya están: no se duplican
document.querySelectorAll('#notas th').forEach((th) => th.removeAttribute('aria-sort'));
boton.closest('th').setAttribute('aria-sort', orden.asc ? 'ascending' : 'descending');
});
});Apartado 3 · JavaScript
const alumnos = JSON.parse(document.getElementById('datos').textContent);
const cuerpo = document.querySelector('#notas tbody');
const resumen = document.getElementById('resumen');
const filtro = document.getElementById('filtro');
const coma = (x) => x.toFixed(2).replace('.', ',');
const datosDe = new Map(); // fila → su alumno
// 1. Las filas se crean con el DOM y textContent: los datos nunca se interpretan como HTML
const filas = alumnos.map((a) => {
const tr = document.createElement('tr');
tr.className = a.nota >= 5 ? 'aprobado' : 'suspenso';
for (const texto of [a.nombre, coma(a.nota), a.nota >= 5 ? 'Aprobado' : 'Suspenso']) {
const td = document.createElement('td');
td.textContent = texto;
tr.append(td);
}
datosDe.set(tr, a);
cuerpo.append(tr);
return tr;
});
function actualizarResumen() {
const visibles = filas.filter((tr) => !tr.hidden).map((tr) => datosDe.get(tr));
const texto = filtro.value.trim();
if (visibles.length === 0) {
resumen.textContent = `Ningún alumno coincide con «${texto}»`;
return;
}
const media = visibles.reduce((s, a) => s + a.nota, 0) / visibles.length;
const aprobados = visibles.filter((a) => a.nota >= 5).length;
resumen.textContent = `${texto ? visibles.length + ' de ' : ''}${alumnos.length} alumnos · media ${coma(media)} · ${aprobados} ${aprobados === 1 ? 'aprobado' : 'aprobados'}`;
}
actualizarResumen();
// 2. Ordenar al pulsar la cabecera: mismo campo, se invierte; otro campo, ascendente
let orden = { campo: null, asc: true };
document.querySelectorAll('#notas th button').forEach((boton) => {
boton.addEventListener('click', () => {
const campo = boton.dataset.campo;
orden = { campo, asc: orden.campo === campo ? !orden.asc : true };
const comparar = campo === 'nota'
? (x, y) => x.nota - y.nota
: (x, y) => x.nombre.localeCompare(y.nombre, 'es');
filas.sort((f, g) => (orden.asc ? 1 : -1) * comparar(datosDe.get(f), datosDe.get(g)));
cuerpo.append(...filas); // append mueve los nodos que ya están: no se duplican
document.querySelectorAll('#notas th').forEach((th) => th.removeAttribute('aria-sort'));
boton.closest('th').setAttribute('aria-sort', orden.asc ? 'ascending' : 'descending');
});
});
// 3. Filtrar mientras se escribe, sin distinguir mayúsculas ni tildes
const normalizar = (s) => s.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
filtro.addEventListener('input', () => {
const texto = normalizar(filtro.value.trim());
for (const tr of filas) tr.hidden = !normalizar(datosDe.get(tr).nombre).includes(texto);
actualizarResumen();
});innerHTML con datos que vienen de fuera es el origen de la mayoría de ataques XSS: si un nombre trae un <script> o un <img onerror=…>, el navegador lo ejecuta. textContent trata todo como texto, y por eso los frameworks escapan los datos por defecto.
Mover nodos en lugar de recrearlos conserva su estado (el foco, una selección, los manejadores de eventos) y es más rápido: el navegador no tiene que construir elementos nuevos.
localeCompare con el idioma ordena como una persona: la Ó junto a la O y la Ñ después de la N. Con < y > se compararían códigos Unicode y Óscar iría después de la Z.
Normalizar el texto (sin tildes y en minúsculas) antes de comparar es lo que hace cualquier buscador decente: nadie escribe las tildes en un buscador.
Para ir más allá
- Permite editar la nota con doble clic, validándola y recalculando el resumen.
- Guarda el orden y el filtro en la URL (
?orden=nota&q=ar) para poder compartir la vista. - Carga los datos con
fetchdesde un fichero JSON en lugar del script de la página.