Una auditoría marcó `<ul role="img">` en el riel de clases de fuego. El rol contradice la semántica implícita de lista, así que la combinación es inválida y el árbol de accesibilidad queda mal formado. La intención original era correcta: el riel debe anunciarse como una sola unidad con su resumen, no como "lista de cinco elementos: A, B, C, D, K", que pierde justo lo que importa, cuáles están encendidas. Lo que estaba mal era el elemento, no el rol. El riel pasa a un contenedor genérico y conserva role="img" con su etiqueta. Al revisar apareció el mismo error en dos gráficas del panel, que además duplicaban el anuncio: leían el resumen completo y después la tabla accesible entera. Ahí la representación visual se oculta al árbol y la tabla equivalente queda como única fuente. También se corrigió el botón del selector de clases, que se anunciaba "A Clase A" por tener la letra visible junto a un texto oculto con el nombre completo. Se agrega una comprobación de regresión que valida el rol contra la semántica de ul, ol, li, table, nav, main, header y footer, para que no vuelva a colarse. Suite completa en verde. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
200 lines
8.7 KiB
TypeScript
200 lines
8.7 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
import { RUTAS_MUESTRA } from "./rutas";
|
|
|
|
/**
|
|
* Accesibilidad sin dependencias externas: comprobaciones escritas a mano de
|
|
* las fallas que de verdad aparecen en un sitio generado — imágenes sin texto
|
|
* alternativo, campos sin etiqueta, saltos en la jerarquía de encabezados y
|
|
* botones que solo tienen un icono dentro.
|
|
*/
|
|
|
|
for (const ruta of RUTAS_MUESTRA) {
|
|
test(`accesibilidad básica en ${ruta}`, async ({ page }) => {
|
|
await page.goto(ruta, { waitUntil: "domcontentloaded" });
|
|
// las comprobaciones miden cajas, así que hace falta que el diseño ya haya
|
|
// asentado; se espera una condición concreta en lugar de la red en reposo
|
|
await page.locator("h1").first().waitFor({ state: "visible" });
|
|
await page.waitForTimeout(400);
|
|
|
|
const problemas = await page.evaluate(() => {
|
|
const fallas: string[] = [];
|
|
|
|
// --- imágenes sin alternativa textual ---
|
|
for (const img of Array.from(document.querySelectorAll("img"))) {
|
|
const alt = img.getAttribute("alt");
|
|
if (alt === null) {
|
|
fallas.push(`img sin atributo alt: ${img.getAttribute("src")?.slice(0, 70)}`);
|
|
}
|
|
}
|
|
|
|
// --- controles sin etiqueta accesible ---
|
|
for (const campo of Array.from(
|
|
document.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>(
|
|
"input, select, textarea",
|
|
),
|
|
)) {
|
|
if (campo instanceof HTMLInputElement && ["hidden", "submit", "button"].includes(campo.type)) continue;
|
|
if (campo.getAttribute("tabindex") === "-1") continue; // señuelo antispam
|
|
|
|
const id = campo.getAttribute("id");
|
|
const tieneEtiqueta = Boolean(
|
|
(id && document.querySelector(`label[for="${id}"]`)) ||
|
|
campo.closest("label") ||
|
|
campo.getAttribute("aria-label") ||
|
|
campo.getAttribute("aria-labelledby"),
|
|
);
|
|
if (!tieneEtiqueta) {
|
|
fallas.push(`control sin etiqueta: ${campo.tagName.toLowerCase()}[name=${campo.getAttribute("name")}]`);
|
|
}
|
|
}
|
|
|
|
// --- botones y enlaces sin nombre accesible ---
|
|
for (const el of Array.from(document.querySelectorAll<HTMLElement>("button, a"))) {
|
|
const r = el.getBoundingClientRect();
|
|
if (r.width === 0 && r.height === 0) continue;
|
|
// Un elemento marcado como oculto al árbol de accesibilidad no aparece
|
|
// para un lector de pantalla, así que no necesita nombre. Es el caso
|
|
// legítimo de la imagen de una tarjeta que duplica el enlace del
|
|
// título: se marca aria-hidden con tabindex -1 justo para no anunciar
|
|
// dos veces el mismo destino. Exigirle nombre era un falso positivo.
|
|
if (el.closest('[aria-hidden="true"]')) continue;
|
|
const texto = (el.textContent ?? "").trim();
|
|
const nombre = texto || el.getAttribute("aria-label") || el.getAttribute("title") || "";
|
|
if (!nombre) {
|
|
fallas.push(`${el.tagName.toLowerCase()} sin nombre accesible`);
|
|
}
|
|
}
|
|
|
|
// --- jerarquía de encabezados sin saltos ---
|
|
const niveles = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6")).map((h) =>
|
|
Number(h.tagName.slice(1)),
|
|
);
|
|
const h1 = niveles.filter((n) => n === 1).length;
|
|
if (h1 !== 1) fallas.push(`se esperaba un solo h1, hay ${h1}`);
|
|
for (let i = 1; i < niveles.length; i++) {
|
|
if (niveles[i]! - niveles[i - 1]! > 1) {
|
|
fallas.push(`salto de encabezado h${niveles[i - 1]} a h${niveles[i]}`);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// --- roles que contradicen la semántica del elemento ---
|
|
//
|
|
// Una auditoría marcó `<ul role="img">` en el riel de clases de fuego:
|
|
// el rol contradice la semántica implícita de lista. El mismo error
|
|
// estaba en dos gráficas del panel. Los elementos de abajo tienen
|
|
// semántica fuerte y no deben recibir un rol arbitrario; lo único
|
|
// razonable es quitarles la semántica con presentation/none, o repetir
|
|
// la que ya tienen.
|
|
const PERMITIDOS: Record<string, string[]> = {
|
|
ul: ["list", "presentation", "none", "menu", "menubar", "listbox", "group", "tablist"],
|
|
ol: ["list", "presentation", "none", "menu", "menubar", "listbox", "group", "tablist"],
|
|
li: ["listitem", "presentation", "none", "menuitem", "option", "tab", "treeitem"],
|
|
table: ["table", "grid", "treegrid", "presentation", "none"],
|
|
nav: ["navigation", "presentation", "none"],
|
|
main: ["main", "presentation", "none"],
|
|
header: ["banner", "group", "presentation", "none"],
|
|
footer: ["contentinfo", "group", "presentation", "none"],
|
|
};
|
|
for (const [etiqueta, roles] of Object.entries(PERMITIDOS)) {
|
|
for (const el of Array.from(document.querySelectorAll(etiqueta))) {
|
|
const rol = el.getAttribute("role");
|
|
if (rol && !roles.includes(rol)) {
|
|
fallas.push(`rol inapropiado para el elemento: <${etiqueta} role="${rol}">`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- listas mal formadas ---
|
|
for (const ul of Array.from(document.querySelectorAll("ul, ol"))) {
|
|
for (const hijo of Array.from(ul.children)) {
|
|
if (!["LI", "SCRIPT", "TEMPLATE"].includes(hijo.tagName)) {
|
|
fallas.push(`<${hijo.tagName.toLowerCase()}> como hijo directo de <${ul.tagName.toLowerCase()}>`);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return fallas;
|
|
});
|
|
|
|
expect(problemas, `${ruta}:\n - ${problemas.join("\n - ")}`).toEqual([]);
|
|
});
|
|
}
|
|
|
|
test("el salto al contenido funciona con teclado", async ({ page }, info) => {
|
|
// Safari no lleva el foco a los enlaces con Tab salvo que la persona active
|
|
// "Presionar Tab para resaltar cada elemento" en sus preferencias. Es
|
|
// comportamiento del navegador, no del sitio, así que la comprobación no
|
|
// aplica en WebKit: ahí el salto sigue existiendo y sigue siendo alcanzable
|
|
// con las teclas de accesibilidad del sistema.
|
|
test.skip(
|
|
info.project.name.includes("webkit"),
|
|
"En Safari el tabulador no enfoca enlaces por omisión; es del navegador, no del sitio.",
|
|
);
|
|
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
await page.keyboard.press("Tab");
|
|
|
|
const enfocado = await page.evaluate(() => {
|
|
const a = document.activeElement as HTMLElement | null;
|
|
return { texto: (a?.textContent ?? "").trim(), href: a?.getAttribute("href") ?? "" };
|
|
});
|
|
expect(enfocado.texto, "el primer tabulador debe llegar al salto de contenido").toMatch(/contenido/i);
|
|
expect(enfocado.href).toBe("#contenido");
|
|
});
|
|
|
|
test("el foco es visible al navegar con teclado", async ({ page }, info) => {
|
|
test.skip(
|
|
info.project.name.includes("webkit"),
|
|
"Misma razón: en Safari el tabulador no recorre los enlaces por omisión.",
|
|
);
|
|
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
for (let i = 0; i < 5; i++) await page.keyboard.press("Tab");
|
|
|
|
const contorno = await page.evaluate(() => {
|
|
const a = document.activeElement as HTMLElement | null;
|
|
if (!a) return null;
|
|
const e = getComputedStyle(a);
|
|
return { outlineWidth: e.outlineWidth, outlineStyle: e.outlineStyle, boxShadow: e.boxShadow };
|
|
});
|
|
expect(contorno, "debe haber algo enfocado").not.toBeNull();
|
|
const visible =
|
|
(contorno!.outlineStyle !== "none" && parseFloat(contorno!.outlineWidth) > 0) ||
|
|
contorno!.boxShadow !== "none";
|
|
expect(visible, "el elemento enfocado necesita indicador visible").toBe(true);
|
|
});
|
|
|
|
test("el widget de WhatsApp se cierra con Escape y devuelve el foco", async ({ page }) => {
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
const disparador = page.getByRole("button", { name: /contactar por whatsapp/i });
|
|
await disparador.click();
|
|
await expect(page.locator("#panel-whatsapp")).toBeVisible();
|
|
|
|
await page.keyboard.press("Escape");
|
|
await expect(page.locator("#panel-whatsapp")).toBeHidden();
|
|
|
|
const devuelto = await page.evaluate(
|
|
() => (document.activeElement as HTMLElement | null)?.getAttribute("aria-controls") ?? "",
|
|
);
|
|
expect(devuelto, "el foco debe volver al disparador").toBe("panel-whatsapp");
|
|
});
|
|
|
|
test("se respeta la preferencia de movimiento reducido", async ({ page }) => {
|
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
|
|
// con movimiento reducido, el contenido revelado debe estar visible, no atorado en opacidad 0
|
|
const ocultos = await page.evaluate(() => {
|
|
let n = 0;
|
|
for (const el of Array.from(document.querySelectorAll<HTMLElement>(".reveal"))) {
|
|
const r = el.getBoundingClientRect();
|
|
if (r.top > window.innerHeight) continue; // fuera de pantalla, no cuenta
|
|
if (parseFloat(getComputedStyle(el).opacity) < 0.5) n++;
|
|
}
|
|
return n;
|
|
});
|
|
expect(ocultos, "con movimiento reducido nada debe quedar invisible").toBe(0);
|
|
});
|