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:
co-authored by
Claude Opus 5
parent
15d31b5d6b
commit
ab9f3ef6d3
@@ -58,13 +58,12 @@ export function BarrasMagnitud({
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<ul
|
||||
role="img"
|
||||
aria-label={`${unidad} por categoría. ${filas
|
||||
.map((f) => `${f.etiqueta}: ${entero(f.valor)}`)
|
||||
.join(". ")}.`}
|
||||
className="grid gap-2.5"
|
||||
>
|
||||
{/*
|
||||
Se oculta al árbol de accesibilidad: la tabla equivalente de abajo lleva
|
||||
los datos. `role="img"` sobre un `<ul>` es inválido y, además, duplicaba
|
||||
el anuncio con la tabla.
|
||||
*/}
|
||||
<ul aria-hidden="true" className="grid gap-2.5">
|
||||
{filas.map((f) => {
|
||||
const anchoTotal = Math.max(f.valor > 0 ? 1.5 : 0, (f.valor / max) * 100);
|
||||
const dest = f.destacado ?? 0;
|
||||
|
||||
@@ -32,11 +32,16 @@ export function Embudo({ embudo }: { embudo: TramoEmbudo[] }) {
|
||||
|
||||
return (
|
||||
<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
|
||||
role="img"
|
||||
aria-label={`Embudo por etapa. ${embudo
|
||||
.map((t) => `${ETAPA_ETIQUETA[t.etapa]}: ${entero(t.cantidad)}`)
|
||||
.join(". ")}.`}
|
||||
aria-hidden="true"
|
||||
className="grid gap-[2px]"
|
||||
style={{ backgroundColor: COLOR_SUPERFICIE }}
|
||||
>
|
||||
|
||||
@@ -53,7 +53,17 @@ export function RielClases({
|
||||
</span>
|
||||
) : 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"
|
||||
role="img"
|
||||
aria-label={resumen}
|
||||
@@ -62,7 +72,7 @@ export function RielClases({
|
||||
{CLASES.map((clase, i) => {
|
||||
const on = activas.includes(clase);
|
||||
return (
|
||||
<li
|
||||
<span
|
||||
key={clase}
|
||||
aria-hidden="true"
|
||||
className={[
|
||||
@@ -79,10 +89,10 @@ export function RielClases({
|
||||
style={animar && on ? { animationDelay: `${240 + i * 90}ms` } : undefined}
|
||||
>
|
||||
{clase}
|
||||
</li>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -120,6 +130,9 @@ export function RielClasesSelector({
|
||||
disabled={disabled}
|
||||
onClick={() => alternar(clase)}
|
||||
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}`}
|
||||
className={[
|
||||
d.celda,
|
||||
@@ -132,7 +145,6 @@ export function RielClasesSelector({
|
||||
].join(" ")}
|
||||
>
|
||||
{clase}
|
||||
<span className="sr-only">Clase {clase}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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 ---
|
||||
for (const ul of Array.from(document.querySelectorAll("ul, ol"))) {
|
||||
for (const hijo of Array.from(ul.children)) {
|
||||
|
||||
Reference in New Issue
Block a user