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>
|
</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;
|
||||||
|
|||||||
@@ -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 }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user