fix(a11y): roles ARIA que contradecían 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, 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]>
This commit is contained in:
Uriel Jareth
2026-08-28 13:20:08 -06:00
co-authored by Claude Opus 5
parent 15d31b5d6b
commit ab9f3ef6d3
4 changed files with 59 additions and 16 deletions
@@ -58,13 +58,12 @@ export function BarrasMagnitud({
</ul> </ul>
) : null} ) : null}
<ul {/*
role="img" Se oculta al árbol de accesibilidad: la tabla equivalente de abajo lleva
aria-label={`${unidad} por categoría. ${filas los datos. `role="img"` sobre un `<ul>` es inválido y, además, duplicaba
.map((f) => `${f.etiqueta}: ${entero(f.valor)}`) el anuncio con la tabla.
.join(". ")}.`} */}
className="grid gap-2.5" <ul aria-hidden="true" className="grid gap-2.5">
>
{filas.map((f) => { {filas.map((f) => {
const anchoTotal = Math.max(f.valor > 0 ? 1.5 : 0, (f.valor / max) * 100); const anchoTotal = Math.max(f.valor > 0 ? 1.5 : 0, (f.valor / max) * 100);
const dest = f.destacado ?? 0; const dest = f.destacado ?? 0;
+9 -4
View File
@@ -32,11 +32,16 @@ export function Embudo({ embudo }: { embudo: TramoEmbudo[] }) {
return ( return (
<figure className="m-0 px-4 py-4 sm:px-5"> <figure className="m-0 px-4 py-4 sm:px-5">
{/*
La representación visual se oculta al árbol de accesibilidad y la tabla
equivalente de más abajo es la que lleva los datos.
Antes esta lista declaraba `role="img"`, que es inválido sobre un `<ol>`
—el rol contradice la semántica de lista— y además duplicaba el
anuncio: se leía el resumen completo y después la tabla entera.
*/}
<ol <ol
role="img" aria-hidden="true"
aria-label={`Embudo por etapa. ${embudo
.map((t) => `${ETAPA_ETIQUETA[t.etapa]}: ${entero(t.cantidad)}`)
.join(". ")}.`}
className="grid gap-[2px]" className="grid gap-[2px]"
style={{ backgroundColor: COLOR_SUPERFICIE }} style={{ backgroundColor: COLOR_SUPERFICIE }}
> >
+17 -5
View File
@@ -53,7 +53,17 @@ export function RielClases({
</span> </span>
) : null} ) : null}
<ul {/*
Contenedor genérico, no una lista.
`role="img"` sobre un `<ul>` es una combinación inválida: el rol
contradice la semántica implícita de lista y las herramientas de
auditoría lo marcan. La intención sí 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.
*/}
<div
className="flex items-center gap-1" className="flex items-center gap-1"
role="img" role="img"
aria-label={resumen} aria-label={resumen}
@@ -62,7 +72,7 @@ export function RielClases({
{CLASES.map((clase, i) => { {CLASES.map((clase, i) => {
const on = activas.includes(clase); const on = activas.includes(clase);
return ( return (
<li <span
key={clase} key={clase}
aria-hidden="true" aria-hidden="true"
className={[ className={[
@@ -79,10 +89,10 @@ export function RielClases({
style={animar && on ? { animationDelay: `${240 + i * 90}ms` } : undefined} style={animar && on ? { animationDelay: `${240 + i * 90}ms` } : undefined}
> >
{clase} {clase}
</li> </span>
); );
})} })}
</ul> </div>
</div> </div>
); );
} }
@@ -120,6 +130,9 @@ export function RielClasesSelector({
disabled={disabled} disabled={disabled}
onClick={() => alternar(clase)} onClick={() => alternar(clase)}
aria-pressed={on} aria-pressed={on}
// El nombre accesible se declara aquí, no con un texto oculto junto
// a la letra visible: eso hacía que se anunciara "A Clase A".
aria-label={`Clase ${clase}`}
title={`Clase ${clase}`} title={`Clase ${clase}`}
className={[ className={[
d.celda, d.celda,
@@ -132,7 +145,6 @@ export function RielClasesSelector({
].join(" ")} ].join(" ")}
> >
{clase} {clase}
<span className="sr-only">Clase {clase}</span>
</button> </button>
); );
})} })}
+27
View File
@@ -78,6 +78,33 @@ for (const ruta of RUTAS_MUESTRA) {
} }
} }
// --- 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 --- // --- listas mal formadas ---
for (const ul of Array.from(document.querySelectorAll("ul, ol"))) { for (const ul of Array.from(document.querySelectorAll("ul, ol"))) {
for (const hijo of Array.from(ul.children)) { for (const hijo of Array.from(ul.children)) {