26. Accesibilidad: WCAG, teclado, ARIA y pruebas
La accesibilidad es la disciplina técnica peor entendida del desarrollo web. Se confunde con una campaña de buenas intenciones, se aplaza al final del proyecto y termina resuelta con una tanda de aria-label puestos a ciegas la semana antes de entregar. Este capítulo la trata como lo que es en un proyecto profesional: un conjunto de requisitos comprobables, con una norma que los define, un árbol de datos concreto que los navegadores construyen y exponen, un contrato de teclado documentado para cada patrón de interacción, y una batería de pruebas —automáticas y manuales— que se ejecutan en el pipeline igual que los tests unitarios. Todo el recorrido se hace sobre TaskFlow, en Angular y en la parte de NestJS que también genera interfaz: correos, PDF e informes.
26.1 Qué vas a poder hacer al terminar
- Explicar qué es el árbol de accesibilidad, cómo lo construye el navegador a partir del DOM y por qué es la única cosa que un lector de pantalla puede leer.
- Calcular a mano el nombre accesible de cualquier elemento y saber qué fuente gana sobre cuál, que es la causa de la mitad de los fallos de etiquetado.
- Leer un criterio de WCAG y traducirlo a una comprobación concreta sobre tu código, distinguiendo con criterio qué exige el nivel A, qué el AA y qué el AAA.
- Sustituir la mayor parte del ARIA de un proyecto por HTML semántico correcto, y justificar técnicamente por qué eso es una mejora y no un atajo.
- Aplicar la primera regla de ARIA y, cuando de verdad haga falta, escribir los roles, estados y propiedades correctos sin dejar el componente en un estado inconsistente.
- Construir cualquier componente interactivo (menú, pestañas, diálogo, combo, árbol) con el contrato de teclado que el estándar de authoring practices especifica.
- Resolver el problema que Angular no resuelve solo: la gestión del foco en una aplicación de una sola página, al cambiar de ruta, al abrir un diálogo y al eliminar un elemento de una lista.
- Diseñar formularios accesibles de verdad: etiquetas asociadas, agrupaciones, campos obligatorios, errores anunciados y validación en el momento adecuado, integrado con los formularios reactivos del capítulo 5.
- Auditar un sistema de tokens de color, comprobar los ratios de contraste que exige la norma y detectar la información que viaja solo en el color.
- Anunciar contenido dinámico —cargas, notificaciones, resultados de búsqueda, tablas que se refrescan— con regiones activas y el nivel de urgencia correcto.
- Usar el paquete de accesibilidad del CDK de Angular (
FocusTrap,LiveAnnouncer,FocusMonitor, gestores de teclado) y saber exactamente qué te da Angular Material de serie y qué no. - Montar una estrategia de pruebas en tres capas: reglas de linter en las plantillas,
axeen la suite automatizada con una puerta en integración continua, y un protocolo manual de teclado y lector de pantalla que cabe en cinco minutos. - Extender la accesibilidad al lado servidor: correos transaccionales, PDF generados e informes descargables.
Las secciones 26.2 a 26.4 son conceptuales y conviene leerlas en orden: sin entender el árbol de accesibilidad y el cálculo del nombre accesible, el resto se convierte en una lista de recetas que se aplican por fe. A partir de 26.5 el capítulo es práctico y cada sección se puede consultar de forma aislada.
26.2 El problema real: quién usa tu aplicación
Cuando un equipo diseña una interfaz, lo hace implícitamente para un usuario concreto: alguien con visión normal, mirando una pantalla grande y bien iluminada, con las dos manos libres, un ratón preciso, una conexión estable y toda su capacidad de atención disponible. Ese usuario existe, pero es una minoría de los momentos de uso reales, no la mayoría. La accesibilidad no consiste en añadir un modo especial para un colectivo aparte: consiste en dejar de asumir que todo el mundo usa la aplicación en las condiciones en las que tú la desarrollas.
26.2.1 Tipos de discapacidad y qué implica cada uno técnicamente
Conviene pensar en categorías funcionales, no en diagnósticos médicos. Lo que importa para el código no es la etiqueta clínica, sino por qué canal entra la información y por qué canal sale la acción.
| Categoría | Cómo interactúa | Qué rompe tu aplicación | Qué tienes que garantizar |
|---|---|---|---|
| Visual · ceguera | Lector de pantalla (voz o braille), teclado exclusivamente | Iconos sin nombre, div con click, imágenes sin alternativa, cambios de contenido que no se anuncian, diálogos sin foco | Que todo lo visible tenga nombre, rol y estado en el árbol de accesibilidad, y que todo sea operable con teclado |
| Visual · baja visión | Zoom del navegador hasta el 400 %, ampliadores, tipografía aumentada, temas de contraste del sistema | Diseños de ancho fijo, texto en imágenes, contraste bajo, contenedores con overflow: hidden que recortan al ampliar | Diseño que reflue sin scroll horizontal, contraste suficiente, tamaños en unidades relativas |
| Visual · daltonismo | Visión normal salvo en la discriminación de determinados pares de color | Estados de tarea diferenciados solo por color, gráficas con leyenda cromática, campos con error marcados solo en rojo | Un segundo canal siempre: texto, icono, forma o patrón |
| Auditiva | Sin audio o con audio parcial; a veces lengua de signos como primera lengua | Vídeos de ayuda sin subtítulos, alertas sonoras sin equivalente visual, videollamadas sin transcripción | Subtítulos y transcripción; nunca el sonido como único canal de notificación |
| Motriz | Teclado, conmutador, control por voz, seguimiento ocular, ratón de cabeza, teclado en pantalla | Objetivos de pulsación diminutos, menús que solo se abren al pasar el ratón, arrastrar y soltar sin alternativa, tiempos de espera cortos | Todo accesible por teclado, áreas de pulsación amplias, alternativa a cualquier gesto, tiempos ampliables |
| Cognitiva y del aprendizaje | Navegación normal, pero con menor tolerancia a la carga cognitiva, la ambigüedad y los cambios inesperados | Formularios largos sin guardar, mensajes de error crípticos, animaciones que distraen, jerga, interfaces que cambian de sitio | Lenguaje claro, errores que dicen cómo arreglarlo, estructura predecible, sin límites de tiempo arbitrarios |
| Vestibular y fotosensibilidad | Visión y motricidad normales, pero el movimiento provoca mareo o crisis | Parallax, transiciones grandes, carruseles automáticos, destellos | Respetar la preferencia de movimiento reducido y no producir destellos |
| Habla | Interfaces de voz inutilizables | Verificación telefónica obligatoria, asistentes solo por voz | Alternativa escrita a cualquier flujo por voz |
26.2.2 Discapacidad situacional y temporal: el argumento que convence a un gestor de producto
Aquí está el punto que suele desbloquear la conversación en un comité de priorización. Las limitaciones no son solo permanentes. Microsoft popularizó una clasificación muy útil que distingue tres duraciones para la misma limitación funcional, y las tres se benefician exactamente de las mismas soluciones técnicas.
| Limitación | Permanente | Temporal | Situacional |
|---|---|---|---|
| Un solo brazo disponible | Amputación | Brazo escayolado, esguince de muñeca | Sujetar a un bebé, llevar una caja, ir en el metro agarrado a la barra |
| No ver bien la pantalla | Baja visión | Dilatación de pupilas tras una revisión, conjuntivitis | Sol directo sobre el portátil en una terraza, brillo bajado al mínimo por batería |
| No oír el audio | Sordera | Otitis, tapón de cera | Obra en la calle, oficina abierta sin auriculares, vídeo en silencio en una reunión |
| No poder usar el ratón | Temblor, parálisis | Tendinitis, dedo vendado | Ratón sin batería, trackpad de un portátil en un tren, tableta sin periférico |
| Atención reducida | TDAH | Migraña, medicación, insomnio | Prisa, interrupciones, tres reuniones seguidas, viernes a las siete de la tarde |
| Conexión inutilizable | Zona sin cobertura | Avería del proveedor | Tren en un túnel, hotel con wifi saturado, roaming limitado |
La consecuencia práctica es que el arreglo accesible casi nunca beneficia solo a quien tiene una discapacidad permanente. El indicador de foco visible sirve al usuario ciego y también al comercial que rellena el formulario de alta de proyecto con el teclado porque va más rápido. Los subtítulos del vídeo de onboarding de TaskFlow los usa la persona sorda y los usa la mayoría silenciosa que ve vídeos sin sonido. Un objetivo de pulsación amplio sirve a quien tiene temblor y a cualquiera que use el móvil de pie en un autobús. Es el mismo efecto que el bordillo rebajado de una acera: se legisló para sillas de ruedas y lo usan carritos, maletas, bicicletas y repartidores.
<div class="boton"> no estás haciendo una interfaz «menos accesible»: estás publicando una API sin contrato, en la que el consumidor no puede saber que aquello es un botón, si está pulsado o si se puede activar. Nadie aceptaría un endpoint que devuelve un blob sin tipo; esto es lo mismo.
26.2.3 El marco legal: por qué esto entra en los pliegos
La razón por la que la accesibilidad aparece en los contratos no es ética, es contractual. En el ámbito europeo hay dos piezas que conviene conocer por nombre, porque son las que citan los pliegos de licitación:
- La norma armonizada EN 301 549, «Requisitos de accesibilidad para productos y servicios TIC». Es el documento técnico de referencia en Europa. Para el contenido web, su capítulo correspondiente remite directamente a WCAG en nivel AA: no reinventa los criterios, los adopta. Además cubre cosas que WCAG no toca, como el hardware, la documentación de soporte o los servicios de atención al cliente. Si un pliego dice «conforme a EN 301 549», en la práctica te está pidiendo WCAG AA más un puñado de requisitos adicionales de documentación.
- Las directivas europeas de accesibilidad. La primera obliga a los organismos del sector público a que sus sitios web y aplicaciones móviles sean accesibles, con declaración de accesibilidad y mecanismo de reclamación. La segunda, conocida como Acta Europea de Accesibilidad, extiende obligaciones a determinados productos y servicios del sector privado —comercio electrónico, banca, transporte, libros electrónicos, entre otros—. Cada Estado miembro las transpone a su propia norma nacional, que es la que finalmente te aplica.
Deliberadamente no cito números de artículo, plazos ni sanciones. La transposición nacional, el ámbito exacto de aplicación, las excepciones por carga desproporcionada y las fechas de entrada en vigor cambian por país y se han modificado varias veces. Lo que sí puedes dar por seguro es la parte técnica: el objetivo verificable es WCAG en nivel AA. Para el alcance legal de un proyecto concreto, la respuesta la da el departamento jurídico del cliente, no un manual técnico.
Hay un segundo efecto legal, menos citado y más frecuente en la práctica: la declaración de accesibilidad. Muchas normativas obligan a publicar un documento que diga en qué grado cumples, qué contenido queda fuera y cómo reclamar. Eso convierte la accesibilidad en algo auditable por terceros con nombres y apellidos. Un equipo que no sabe qué criterios incumple no puede firmar esa declaración, y firmarla en falso es un riesgo mucho mayor que reconocer una excepción concreta.
26.2.4 El coste de arreglarlo tarde
El argumento económico es el que decide las prioridades, así que merece la pena plantearlo con precisión. La accesibilidad tiene una propiedad incómoda: su coste de corrección crece con la profundidad arquitectónica del fallo, no con el número de pantallas afectadas.
COSTE DE CORREGIR EL MISMO FALLO SEGÚN CUÁNDO SE DETECTA
DISEÑO Paleta con contraste insuficiente para el texto secundario.
───────── Arreglo: cambiar dos valores en el archivo de tokens.
coste: 1 Nadie ha escrito código todavía.
DESARROLLO El componente <app-boton-icono> nace sin nombre accesible.
───────── Arreglo: añadir la entrada `etiqueta` obligatoria al componente.
coste: 3 Hay 4 usos; el compilador te los señala uno a uno.
QA / PREPRO Los 180 usos de <app-boton-icono> están repartidos por 40 pantallas.
───────── Arreglo: mismo cambio + revisar 180 llamadas + redactar 180 textos
coste: 30 + volver a probar 40 pantallas.
PRODUCCIÓN Auditoría externa. El «botón» es un <div> con (click) heredado
───────── de un componente base del que dependen 12 widgets, ninguno
coste: 150 recibe foco y el diálogo que abren no atrapa el foco.
Arreglo: rediseño del componente base + 12 widgets + regresión
visual completa + plazo legal encima.
Los saltos de escala del diagrama no son arbitrarios, responden a tres mecanismos concretos:
- Multiplicación por reutilización. Un componente compartido mal diseñado propaga el fallo a cada uso. Es la misma matemática que una firma de método mal elegida: cuanto más se usa, más caro es cambiarla.
- Acoplamiento con el diseño visual. Corregir el contraste en producción implica tocar la identidad visual del producto, y eso deja de ser una decisión de ingeniería para convertirse en una negociación con diseño, marketing y a veces dirección.
- Contenido, no solo código. Los textos alternativos, las etiquetas de los iconos, los subtítulos y los mensajes de error son contenido. Escribir 180 textos alternativos útiles requiere a alguien que conozca el dominio, y ese alguien no es quien arregla el código.
La conclusión operativa es sencilla y es la que aplicaremos en el resto del capítulo: la accesibilidad se resuelve en los componentes base y en los tokens de diseño. Si el botón de icono de TaskFlow exige un nombre accesible en su API, si el diálogo del sistema atrapa el foco por construcción y si la paleta cumple contraste desde el archivo de tokens, entonces la accesibilidad del 90 % de la aplicación es una consecuencia automática, no una tarea recurrente.
// El nombre accesible es OPCIONAL: cada uso decide, y casi
// ninguno se acuerda. Con 180 usos, 150 quedarán sin nombre.
@Component({
selector: 'app-boton-icono',
template: `<button class="icono"><svg>…</svg></button>`,
})
export class BotonIconoComponent {
readonly icono = input.required<string>();
readonly etiqueta = input<string>(); // opcional = inexistente
}
// El nombre accesible es parte del CONTRATO del componente.
// Olvidarlo es un error de compilación, no un hallazgo de auditoría.
@Component({
selector: 'app-boton-icono',
template: `<button class="icono" type="button"
[attr.aria-label]="etiqueta()">
<svg aria-hidden="true" focusable="false">…</svg>
</button>`,
})
export class BotonIconoComponent {
readonly icono = input.required<string>();
readonly etiqueta = input.required<string>(); // obligatoria
}
Fíjate en lo que hace realmente la versión correcta: convierte un requisito de accesibilidad en una restricción del sistema de tipos. Ese es el patrón mental de todo el capítulo. Cada vez que puedas mover una regla de accesibilidad desde «hay que acordarse» hasta «el compilador, el linter o el test lo impiden», habrás hecho el trabajo bien.
26.3 Cómo funciona un lector de pantalla por dentro
Es imposible razonar sobre accesibilidad sin un modelo mental correcto de qué lee un lector de pantalla. La intuición habitual —«lee el texto de la pantalla»— es falsa y conduce a errores sistemáticos. Un lector de pantalla no ve la pantalla y no lee el DOM. Lee una estructura de datos intermedia que el navegador construye y publica: el árbol de accesibilidad.
26.3.1 El árbol de accesibilidad
El navegador mantiene varias representaciones paralelas del documento. El árbol del DOM es la estructura de nodos. El árbol de renderizado (o de layout) es lo que se pinta, con cajas y posiciones. Y existe un tercero, el árbol de accesibilidad, que es una proyección del DOM pensada para tecnologías de asistencia: elimina lo que no aporta semántica, añade la información implícita de cada elemento HTML y expone cada nodo con cuatro datos fundamentales.
| Dato | Qué es | Ejemplo en TaskFlow |
|---|---|---|
| Rol (role) | Qué tipo de cosa es. Determina cómo se anuncia y qué teclas se esperan. | button, link, textbox, checkbox, dialog, row |
| Nombre (accessible name) | La cadena que identifica al elemento para el usuario. | «Marcar como hecha», «Prioridad», «Cerrar diálogo» |
| Estado (state) | Información que cambia con la interacción. | checked, expanded, disabled, invalid, selected, busy |
| Propiedades | Metadatos estables o relaciones con otros nodos. | aria-describedby, aria-required, aria-haspopup, aria-level |
Hay un quinto elemento implícito que suele olvidarse: la descripción accesible, un texto secundario y opcional que se anuncia después del nombre y con una pausa. Es donde van las instrucciones de ayuda y los mensajes de error de un campo.
DEL DOM AL SONIDO: LA CADENA COMPLETA
1. HTML 2. DOM 3. ÁRBOL DE ACCESIBILIDAD
───────────────────── ─────────────────── ─────────────────────────────────
<main> main main
<h1>Tareas</h1> ├─ h1 ├─ heading nivel=1 "Tareas"
<div class="wrap"> ├─ div.wrap ────────► │ (el div no aporta semántica:
<ul> │ └─ ul │ NO aparece en el árbol)
<li> │ └─ li ├─ list "3 elementos"
<input type="checkbox" │ ├─ input │ └─ listitem
id="t1"> │ └─ label │ ├─ checkbox "Revisar API"
<label for="t1"> │ │ │ estado: checked=false
Revisar API │ │ │ propiedad: required=false
</label> │ │ └─ (el label se ha CONSUMIDO
</li> │ │ como nombre del checkbox)
<span aria-hidden="true"> └─ span[aria-hidden] │
✔ └─ (rama podada: aria-hidden)
</span>
</main>
4. API DE ACCESIBILIDAD DEL SISTEMA OPERATIVO
─────────────────────────────────────────────────────────────────────────
Windows: UI Automation / IAccessible2 · macOS/iOS: NSAccessibility
Android: AccessibilityNodeInfo · Linux: AT-SPI
El navegador PUBLICA el árbol en esta API; no habla con el lector.
5. LECTOR DE PANTALLA 6. SALIDA
───────────────────────────────────── ────────────────────────────────
Consulta la API, aplica su propio Voz: "Revisar API,
diccionario de verbosidad, decide casilla, no marcada"
el orden y el idioma de la voz. Braille: revisar api ( )
CONSECUENCIA CLAVE
─────────────────────────────────────────────────────────────────────────
Si un dato (rol, nombre, estado) no está en el paso 3, NO EXISTE.
Da igual que se vea perfectamente en pantalla en el paso 2.
De este diagrama se derivan casi todas las reglas prácticas del capítulo. Vale la pena hacerlas explícitas:
- Los elementos sin semántica no aparecen. Un
<div>o un<span>sin rol ni contenido textual se poda del árbol. Por eso un<div>con un manejador de clic es literalmente invisible: el lector no tiene nada que anunciar. - El árbol es más plano que el DOM. Doce niveles de
divde maquetación se colapsan. Eso es bueno: la estructura que percibe el usuario de lector es la semántica, no la de tu sistema de rejillas. - Algunos nodos se consumen. El texto de un
<label>o el contenido de un<button>se convierten en el nombre del elemento padre y no se anuncian dos veces. - El árbol es vivo. Cada cambio en el DOM lo actualiza y el navegador emite eventos hacia la API del sistema. Esto es lo que permite que funcione
aria-live, y también lo que hace que un cambio masivo de DOM sin avisar deje al usuario desorientado. - Lo oculto visualmente suele estar oculto también aquí.
display: none,visibility: hidden, el atributohiddenyaria-hidden="true"excluyen del árbol. En cambio, la técnica de «solo para lectores de pantalla» (posición absoluta con un recorte de 1 píxel) sí aparece. - El lector de pantalla decide la locución final. El navegador publica datos; el lector elige palabras, orden, idioma y nivel de detalle. Por eso el mismo HTML se oye distinto en NVDA y en VoiceOver, y por eso probar en uno solo no basta.
No es teórico, puedes verlo. En Chrome y Edge, panel Elements → pestaña Accessibility del inspector lateral: muestra rol, nombre calculado, la fuente exacta de la que sale ese nombre y el árbol completo. En Firefox hay un panel Accessibility dedicado con un comprobador de contraste y una comprobación de problemas. En Safari, el inspector muestra el nodo de accesibilidad en la pestaña Node. Adquiere el hábito de mirar ese panel al construir un componente interactivo: es el equivalente de mirar la respuesta real de la API en lugar de suponerla.
26.3.2 El nombre accesible y cómo se calcula
El nombre accesible es la cadena que identifica un elemento. Es el dato más importante del árbol y el que más se estropea, porque no se escribe en un solo sitio: el navegador lo calcula a partir de varias fuentes posibles siguiendo un algoritmo especificado, con una jerarquía estricta. Conocer esa jerarquía es lo que te permite explicar por qué un botón se anuncia «botón» a secas teniendo texto dentro.
CÁLCULO DEL NOMBRE ACCESIBLE (orden de precedencia, se para en el primero que exista)
┌─ 1 ─ aria-labelledby ─► gana SIEMPRE. Concatena el texto de los ids
│ referenciados, en el orden en que se listan.
│ Ignora por completo lo que haya dentro.
│
├─ 2 ─ aria-label ─► cadena literal. Pisa el contenido visible.
│
├─ 3 ─ NOMBRE NATIVO DEL HOST (depende del elemento)
│ <input>, <select>, <textarea> ─► su <label for> o label envolvente
│ <img> ─► atributo alt
│ <fieldset> ─► su <legend>
│ <table> ─► su <caption>
│ <a>, <button>, <th>, <summary> ─► su contenido de texto
│ <svg> ─► su <title> hijo
│
├─ 4 ─ CONTENIDO DE TEXTO ─► solo para roles que lo permiten (button, link,
│ heading, option, cell, tab…). NO para textbox.
│
└─ 5 ─ atributo title ─► último recurso. Frágil: no se ve con teclado,
no se ve en táctil, algunos lectores lo omiten.
Si tras los cinco pasos el nombre es la cadena vacía ─► ELEMENTO SIN NOMBRE.
El lector anuncia solo el rol: "botón", "enlace", "casilla".
Es el fallo de accesibilidad número uno en aplicaciones reales.
aria-label y aria-labelledby sustituyen el contenido visible; no lo complementan. <button aria-label="Guardar">Guardar borrador</button> se anuncia «Guardar, botón». El usuario de control por voz dice «pulsa guardar borrador» porque es lo que lee en pantalla, y no ocurre nada. Es además un incumplimiento directo del criterio 2.5.3 Label in Name: el nombre accesible debe contener el texto visible. Si necesitas añadir información, usa aria-describedby, que se anuncia además del nombre.
<!-- 1. sin nombre: solo hay un icono, y el svg se poda -->
<button><svg class="i-trash"></svg></button>
<!-- 2. el aria-label PISA el texto visible: rompe 2.5.3 -->
<button aria-label="Eliminar">Borrar tarea</button>
<!-- 3. title como único nombre: frágil y no visible con teclado -->
<button title="Duplicar"><svg class="i-copy"></svg></button>
<!-- 4. nombre no único: 40 filas anuncian "Editar, botón" -->
<button>Editar</button>
<!-- 5. el placeholder NO es un nombre fiable -->
<input type="text" placeholder="Título de la tarea">
<!-- 1. nombre explícito; el icono queda fuera del árbol -->
<button type="button" aria-label="Eliminar tarea">
<svg class="i-trash" aria-hidden="true" focusable="false"></svg>
</button>
<!-- 2. el nombre CONTIENE el texto visible -->
<button type="button" aria-label="Borrar tarea Revisar API">
Borrar tarea
</button>
<!-- 3. texto real oculto visualmente: siempre funciona -->
<button type="button">
<svg class="i-copy" aria-hidden="true" focusable="false"></svg>
<span class="solo-lectores">Duplicar tarea</span>
</button>
<!-- 4. nombre único por fila, compuesto con labelledby -->
<h3 id="t-42">Revisar API</h3>
<button type="button" id="ed-42" aria-labelledby="ed-42 t-42">Editar</button>
<!-- 5. label de verdad, asociada por id -->
<label for="titulo">Título de la tarea</label>
<input type="text" id="titulo">
El caso 4 merece un comentario, porque es un patrón que se usa muchísimo y casi nadie conoce. Un usuario de lector de pantalla navega a menudo pidiendo «lista de botones de esta página». Si la tabla de tareas de TaskFlow tiene cuarenta filas, esa lista muestra cuarenta entradas idénticas: «Editar, Editar, Editar…». La solución es componer el nombre con aria-labelledby apuntando primero al propio elemento y después al título de la fila. El navegador concatena en el orden indicado y produce «Editar Revisar API». El texto visible sigue siendo «Editar», el nombre accesible es único y contiene el texto visible, así que se cumple 2.5.3.
/* Texto disponible para el árbol de accesibilidad, invisible en pantalla.
NO uses display:none ni visibility:hidden: eliminan el nodo del árbol.
Tampoco font-size:0 ni text-indent:-9999px: rompen el braille y el zoom. */
.solo-lectores {
position: absolute;
width: 1px;
height: 1px;
margin: -1px; /* evita que el píxel afecte al flujo */
padding: 0;
overflow: hidden;
clip-path: inset(50%); /* recorte moderno; clip está obsoleto */
white-space: nowrap; /* sin esto, el texto se parte y algunos
lectores lo leen letra a letra */
border: 0;
}
/* Variante para enlaces de salto: invisible hasta que recibe el foco.
Si el elemento es enfocable, DEBE poder verse al enfocarlo (2.4.7). */
.solo-lectores-focusable:not(:focus):not(:focus-within) {
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}26.3.3 Rol, estado y propiedades: el contrato de un componente
El rol no es una etiqueta descriptiva: es un contrato. Cuando declaras role="checkbox" estás prometiendo tres cosas a la vez, y las tres son obligatorias:
- Que expones el estado que ese rol requiere. Un
checkboxsinaria-checkedes un componente roto: el lector anuncia «casilla» y no puede decir si está marcada. - Que respondes al teclado como ese rol. El usuario que oye «casilla» pulsará Espacio. Si tu componente solo escucha
clickde ratón, has mentido. - Que la estructura alrededor es la que el rol exige. Un
role="option"tiene que estar dentro de unrole="listbox"; unrole="tab", dentro de unrole="tablist". Fuera de su contenedor, muchos lectores ignoran el rol.
La diferencia entre estado y propiedad es práctica, no cosmética. Los estados cambian durante la vida del componente y hay que actualizarlos en cada interacción; las propiedades se definen una vez y describen algo estructural. Esta tabla recoge los que de verdad se usan a diario:
| Atributo | Tipo | Para qué | Dónde aparece en TaskFlow |
|---|---|---|---|
aria-checked | Estado | true / false / mixed | Casilla «seleccionar todas» en estado indeterminado |
aria-expanded | Estado | Si el elemento controla algo desplegado | Acordeón de filtros, menú de acciones, árbol de proyectos |
aria-selected | Estado | Selección dentro de un conjunto (pestañas, listbox, rejilla) | Pestaña activa de «Mis tareas / Del equipo» |
aria-disabled | Estado | Inoperativo pero enfocable y anunciable | Botón «Guardar» durante el envío |
aria-invalid | Estado | El valor no supera la validación | Campos del formulario de tarea |
aria-busy | Estado | La región se está actualizando; evita anuncios parciales | Tabla de tareas mientras recarga |
aria-current | Estado | page, step, date, true… | Enlace de la sección activa en la navegación lateral |
aria-required | Propiedad | Campo obligatorio (mejor el required nativo) | Título y proyecto en el alta de tarea |
aria-describedby | Propiedad | Texto adicional tras el nombre | Ayuda del campo y mensaje de error |
aria-haspopup | Propiedad | Anuncia que se abrirá un menú, diálogo o listbox | Botón de tres puntos de cada fila |
aria-controls | Propiedad | Qué región gobierna este control | Pestaña → su panel; botón → su desplegable |
aria-live | Propiedad | Región cuyos cambios se anuncian solos | Contador de resultados, avisos de guardado |
aria-level | Propiedad | Profundidad en una jerarquía | Nodos del árbol de proyectos |
aria-setsize / aria-posinset | Propiedad | «Elemento 7 de 250» en listas virtualizadas | Lista de tareas con scroll virtual del CDK |
disabled frente a aria-disabled: no son intercambiables
El atributo nativo disabled saca el elemento del orden de tabulación y, en la mayoría de navegadores, le quita la posibilidad de recibir foco. Consecuencia: el usuario de teclado que va tabulando por la barra de acciones ni se entera de que ese botón existe, y quien usa lector de pantalla no puede leer el aria-describedby que explicaría por qué no se puede pulsar. aria-disabled="true", en cambio, mantiene el elemento enfocable y anunciable como «no disponible», pero no impide el clic: tienes que ignorar la acción tú en el manejador. Regla práctica: disabled nativo en campos de formulario cuyo valor no debe enviarse; aria-disabled en botones de acción que quieres que sigan siendo descubribles y explicables.
26.3.4 Los lectores reales y en qué se diferencian
Escribir para «el lector de pantalla» en abstracto es como escribir CSS para «el navegador». Hay varios, con cuotas de uso muy distintas por plataforma, y sus diferencias de comportamiento son la razón por la que el ARIA exótico falla en producción aunque la especificación diga que debería funcionar.
| Lector | Plataforma | Coste | Navegador de referencia | Lo que te conviene saber |
|---|---|---|---|---|
| NVDA | Windows | Libre y gratuito | Firefox y Chrome | Es el que debes instalar para probar. Muy usado, muy fiel a la especificación y con un visor de voz que muestra por escrito lo que dice: perfecto para documentar una incidencia. |
| JAWS | Windows | Comercial, licencia cara | Chrome y Edge | Dominante en entornos corporativos y administración pública. Es el más «interpretativo»: aplica heurísticas propias y a veces anuncia cosas que otros no, o silencia lo que considera redundante. Si el cliente es una administración, exige pruebas aquí. |
| VoiceOver | macOS e iOS | Incluido en el sistema | Safari | En iOS es prácticamente el único, así que su comportamiento define la accesibilidad móvil de tu producto. Fuera de Safari se comporta peor. Su modo de navegación por gestos hace que patrones basados solo en keydown sean insuficientes. |
| TalkBack | Android | Incluido en el sistema | Chrome | Soporte de ARIA más limitado que en escritorio y muy dependiente de la versión. Regla de oro: en móvil, cuanto más HTML nativo y menos ARIA, mejor. |
| Narrador | Windows | Incluido | Edge | Ha mejorado mucho. Útil como segunda opinión en Windows sin instalar nada. |
| Orca | Linux | Libre | Firefox | El que usarás si desarrollas en Linux y no quieres montar una máquina virtual con Windows. |
Las diferencias que de verdad te van a morder son estas cuatro, y conviene tenerlas presentes antes de diseñar un componente:
- Modo de navegación frente a modo de formulario. Los lectores de Windows tienen dos modos. En modo lectura, las teclas son atajos del propio lector (H salta al siguiente encabezado, B al siguiente botón, T a la siguiente tabla) y no llegan a tu aplicación. Al entrar en un campo de texto o en un widget con rol de aplicación, el lector cambia a modo formulario y las teclas pasan al DOM. Un componente que asume que siempre recibe las pulsaciones de flecha se comporta de forma imprevisible según el modo. Los roles ARIA que declaras son justo lo que decide ese cambio de modo.
- Verbosidad y silencios. Cada lector decide qué es redundante. Uno anuncia «región principal, marco»; otro no dice nada. Si tu diseño depende de que el usuario oiga literalmente una frase concreta, no es robusto.
- Soporte real de ARIA. Los roles y estados antiguos y ampliamente implantados (
button,dialog,tab,aria-expanded,aria-live) funcionan en todas partes. Los más recientes o exóticos (role="feed",aria-details,role="treegrid"con celdas editables) tienen soporte irregular. La página de referencia sobre soporte de ARIA por lector y navegador es de consulta obligada antes de apostar por un rol poco común. - El braille no es voz. Una línea braille muestra pocos caracteres y sin entonación. Nombres larguísimos generados automáticamente («botón eliminar tarea número 42 del proyecto migración de la plataforma de pagos») son inservibles ahí. Sé conciso.
Dedica una hora, una sola vez en tu vida, a aprender lo mínimo de NVDA: arrancarlo, moverte con Tab y flechas, listar encabezados con NVDA+F7, silenciar con Ctrl y abrir el visor de voz. Con eso ya puedes verificar el 90 % de lo que escribes en este capítulo. La sensación de oír tu propia pantalla anunciando «botón, botón, botón, en blanco, casilla» cambia la forma de programar más que cualquier argumento de un manual.
26.4 WCAG explicado de forma útil
Las Pautas de Accesibilidad para el Contenido Web (WCAG, por sus siglas en inglés) son la norma técnica que define qué significa «accesible» de forma comprobable. Las publica el W3C a través de su Iniciativa de Accesibilidad Web y son el documento al que remiten prácticamente todas las legislaciones del mundo, incluida la norma europea EN 301 549. Su estructura es jerárquica y merece la pena entenderla antes de leer un criterio suelto.
ESTRUCTURA DE WCAG
4 PRINCIPIOS Perceptible · Operable · Comprensible · Robusto
│
├── 13 PAUTAS objetivos generales, NO comprobables
│ │ («1.4 Distinguible», «2.4 Navegable»)
│ │
│ └── ~90 CRITERIOS DE CONFORMIDAD ◄── ESTO es lo que se audita
│ │ Verificables: se cumplen o no.
│ │ Cada uno tiene nivel A, AA o AAA.
│ │
│ ├── Técnicas suficientes formas conocidas de cumplirlo
│ └── Fallos documentados formas conocidas de incumplirlo
│
└── Documentos de apoyo (informativos, NO normativos)
"Understanding WCAG" · "Techniques for WCAG"
SOLO los criterios de conformidad son normativos.
Las técnicas son sugerencias: puedes cumplir un criterio de otra forma.
26.4.1 Los cuatro principios
Los principios no se auditan, pero son la mejor herramienta de diagnóstico que existe. Cuando encuentres un problema y no sepas por dónde atacarlo, pregúntate a cuál de los cuatro pertenece: la respuesta suele contener el arreglo.
1. Perceptible
La información debe poder llegar a los sentidos del usuario por más de un canal. Si un dato solo existe como imagen, como color o como sonido, hay alguien que no lo recibe.
En TaskFlow: el estado de una tarea no puede ser solo un punto de color; el gráfico de carga del sprint necesita alternativa textual; el texto secundario necesita contraste suficiente.
2. Operable
Todo lo que se pueda hacer debe poder hacerse con cualquier dispositivo de entrada y sin presiones de tiempo arbitrarias. En la práctica, el teclado es el denominador común.
En TaskFlow: reordenar el tablero no puede exigir arrastrar; el menú de acciones de una fila debe abrirse con teclado; la sesión no puede caducar en silencio.
3. Comprensible
La interfaz debe ser predecible y el contenido, entendible. Nada debe cambiar de comportamiento sin que el usuario lo haya pedido, y los errores deben explicar cómo arreglarlos.
En TaskFlow: un select no navega al cambiarlo; el error de fecha dice el formato esperado; la navegación está en el mismo sitio en todas las pantallas.
4. Robusto
El contenido debe funcionar con tecnologías de asistencia presentes y futuras. Traducido: usa las interfaces estándar (HTML semántico, ARIA correcto) y no dependas de un navegador concreto.
En TaskFlow: cada componente propio expone nombre, rol y valor; los mensajes de estado se anuncian; no hay ARIA inventado ni roles usados fuera de su contenedor.
26.4.2 Niveles A, AA y AAA: por qué el objetivo profesional es AA
Cada criterio de conformidad tiene asignado un nivel. Es importante entender que los niveles no son grados de calidad de un mismo criterio, sino una clasificación de criterios distintos por su impacto y por la dificultad de aplicarlos de forma general.
| Nivel | Qué agrupa | Ejemplos representativos | Uso real |
|---|---|---|---|
| A | Lo imprescindible: sin esto, hay usuarios que no pueden usar la aplicación en absoluto. Ningún criterio de nivel A es discutible. | Contenido no textual con alternativa (1.1.1), funcionalidad accesible por teclado (2.1.1), información y relaciones expresadas en el marcado (1.3.1), nombre/función/valor de cada componente (4.1.2) | Suelo absoluto. Un incumplimiento de nivel A es un defecto, no una mejora pendiente. |
| AA | Barreras significativas que se pueden eliminar de forma razonable en cualquier producto. | Contraste mínimo (1.4.3), foco visible (2.4.7), reflujo sin scroll horizontal (1.4.10), mensajes de estado anunciados (4.1.3), sugerencia ante errores (3.3.3) | El objetivo. Es el nivel que exigen las normativas y los pliegos, y el que debes escribir en la definición de «terminado» de tu equipo. |
| AAA | Mejoras adicionales, algunas de las cuales son imposibles de aplicar a determinados tipos de contenido. | Contraste mejorado (1.4.6), lengua de signos para el audio pregrabado, nivel de lectura, ausencia total de animación provocada por la interacción (2.3.3) | Se adopta por criterios sueltos, no en bloque. El propio W3C indica que no se recomienda exigir AAA como política general para sitios completos. |
La conformidad en WCAG es por página completa y por proceso completo, no por componente. Dos consecuencias prácticas. Primera: no puedes declarar «AA salvo el reproductor de vídeo»; si el reproductor está en la página, la página no es conforme. Segunda: si un proceso tiene varios pasos —el alta de proyecto de TaskFlow con sus cuatro pantallas—, todos los pasos deben ser conformes, porque un paso inaccesible bloquea el proceso entero por muy accesibles que sean los otros tres. Esto cambia radicalmente cómo se priorizan los arreglos: primero los flujos críticos de extremo a extremo, no los componentes con más incidencias.
26.4.3 Versiones: 2.0, 2.1 y 2.2
Las versiones de WCAG son retrocompatibles por diseño: cada una añade criterios y no elimina los anteriores, con una única excepción notable. Si cumples 2.2 en nivel AA, cumples también 2.1 y 2.0 en el mismo nivel.
- WCAG 2.0 es la base histórica y la que aún citan algunas normativas antiguas. Se escribió antes de que el móvil y las aplicaciones de una sola página fueran lo normal, y eso se nota.
- WCAG 2.1 añade lo que faltaba para el mundo móvil y táctil, y para la baja visión: reflujo, espaciado del texto, orientación, contraste de elementos no textuales, gestos de puntero y contenido que aparece al pasar el ratón. Es la versión que la norma europea EN 301 549 toma como referencia en su edición vigente cuando se escribe este capítulo.
- WCAG 2.2 añade criterios muy pegados a problemas cotidianos de aplicaciones de gestión: foco no oscurecido por barras fijas, tamaño mínimo del objetivo de pulsación, alternativa a los movimientos de arrastre, no repetir información ya introducida y autenticación accesible sin pruebas cognitivas. La excepción a la retrocompatibilidad está aquí: el antiguo criterio de parsing (4.1.1) quedó obsoleto porque los navegadores modernos y el DOM lo hacían irrelevante.
Antes de firmar un compromiso, comprueba qué versión y qué nivel pide el pliego: no es lo mismo «WCAG 2.1 AA» que «WCAG 2.2 AA». Y cuando cites un umbral numérico, verifícalo en el texto del criterio: los valores de contraste y de tamaño de objetivo tienen excepciones específicas (texto decorativo, logotipos, controles en línea dentro de un párrafo, elementos cuyo espaciado compensa el tamaño) que se olvidan con facilidad y que cambian el resultado de una auditoría.
26.4.4 Los criterios que de verdad incumplen los proyectos reales
De los aproximadamente noventa criterios, un puñado concentra la inmensa mayoría de los hallazgos en auditorías de aplicaciones de gestión como TaskFlow. Esta tabla es la que conviene tener a mano en la revisión de código: cada fila tiene el criterio, el fallo tal y como aparece en un proyecto y el arreglo concreto.
| Criterio (nivel) | Fallo concreto | Arreglo |
|---|---|---|
| 1.1.1 Contenido no textual (A) | Botones de icono sin nombre; el gráfico de progreso del sprint es un <canvas> mudo | aria-label o texto oculto en el botón; para el gráfico, role="img" con nombre que exprese la conclusión y una tabla de datos equivalente |
| 1.3.1 Información y relaciones (A) | <div class="h-titulo"> en vez de encabezado; tabla de tareas con <td> en la fila de cabecera; grupo de radios sin agrupar | Encabezados reales, <th scope="col">, <fieldset> con <legend> |
| 1.3.5 Identificar el propósito de la entrada (AA) | Campos de correo, nombre y teléfono sin autocomplete | autocomplete="email", "name", "tel"; usa la lista de valores del estándar HTML, no cadenas inventadas |
| 1.4.1 Uso del color (A) | Los cuatro estados de tarea se distinguen solo por el color del punto; los campos con error solo tienen el borde rojo | Segundo canal: icono con forma distinta, texto del estado, o patrón |
| 1.4.3 Contraste mínimo (AA) | Texto gris claro sobre blanco para metadatos («creada hace 3 días»); placeholder ilegible; texto blanco sobre el color de marca | 4.5:1 para texto normal y 3:1 para texto grande. Corregir en los tokens, no en el componente |
| 1.4.4 Cambio de tamaño del texto (AA) | Tarjeta de tarea con height fijo en píxeles: al ampliar al 200 % el texto se recorta | min-height en lugar de height, unidades relativas, y probar el zoom del navegador al 200 % |
| 1.4.10 Reflujo (AA) | La tabla de tareas obliga a hacer scroll horizontal de toda la página en una ventana estrecha | Vista de tarjetas por debajo del punto de ruptura, o scroll horizontal confinado al contenedor de la tabla (con tabindex="0" para que sea alcanzable por teclado) |
| 1.4.11 Contraste no textual (AA) | Bordes de campo apenas visibles; el indicador de foco es un halo del color de marca sobre fondo claro; iconos de estado de bajo contraste | 3:1 frente a los colores adyacentes para bordes de controles, indicadores de estado y objetos gráficos necesarios para entender el contenido |
| 1.4.13 Contenido al recibir foco o puntero (AA) | El tooltip de ayuda desaparece al mover el ratón hacia él y no se puede cerrar con teclado | Debe ser descartable con Esc, permanecer mientras el puntero está sobre él y no desaparecer solo por tiempo |
| 2.1.1 Teclado (A) | <div (click)> como botón; reordenar el tablero solo se puede arrastrando; el desplegable solo se abre con mouseenter | <button> real; alternativa «mover arriba / mover abajo» en el menú de la tarjeta; apertura con clic y teclado |
| 2.2.1 Tiempo ajustable (A) | La sesión de TaskFlow expira a los 15 minutos y se pierde el formulario a medias | Avisar antes de expirar con opción de prolongar, y conservar el borrador |
| 2.4.1 Evitar bloques (A) | Hay que pulsar Tab más de treinta veces para pasar del menú lateral al contenido | Enlace de salto al contenido como primer elemento enfocable, más puntos de referencia correctos |
| 2.4.2 Página titulada (A) | Todas las rutas de la aplicación comparten <title>TaskFlow</title> | Title del router de Angular por ruta: «Tarea 42 · Proyecto Pagos · TaskFlow» |
| 2.4.3 Orden del foco (A) | tabindex="3" repartidos por la plantilla; el diálogo se inserta al final del <body> y el foco sigue detrás | Orden del DOM igual al orden visual; sin tabindex positivos; gestión explícita del foco al abrir capas |
| 2.4.4 Propósito de los enlaces (A) | Cuarenta enlaces «Ver detalle» idénticos en la lista de tareas | Texto descriptivo, o nombre compuesto con aria-labelledby que incluya el título de la tarea |
| 2.4.7 Foco visible (AA) | *:focus { outline: none } en la hoja de estilos base | Nunca eliminar sin sustituir: estilo propio con :focus-visible y contraste suficiente |
| 2.4.11 Foco no oscurecido (AA, 2.2) | La barra de acciones fija al pie tapa el último campo cuando recibe el foco | scroll-margin en los elementos enfocables o scroll-padding-bottom en el contenedor con scroll |
| 2.5.3 Etiqueta en el nombre (A) | <button aria-label="Guardar">Guardar cambios</button>: el control por voz falla | El nombre accesible debe contener el texto visible, preferiblemente al principio |
| 2.5.7 Movimientos de arrastre (AA, 2.2) | El tablero kanban solo permite cambiar de columna arrastrando la tarjeta | Menú «Mover a…» en cada tarjeta, con la misma funcionalidad y sin arrastre |
| 2.5.8 Tamaño del objetivo mínimo (AA, 2.2) | Tres iconos de acción de 16 píxeles pegados en la esquina de la fila | Área de pulsación de al menos 24 por 24 píxeles CSS, o separación equivalente entre objetivos |
| 3.1.1 Idioma de la página (A) | <html> sin lang: el lector pronuncia el español con fonética inglesa | <html lang="es">, y lang en los fragmentos en otro idioma |
| 3.2.2 Al recibir entradas (A) | El <select> de proyecto navega a otra pantalla al cambiar de valor | Cambiar el valor no dispara acciones: añade un botón «Aplicar» |
| 3.3.1 Identificación de errores (A) | Al enviar el formulario, los campos inválidos solo cambian el color del borde | Mensaje de texto asociado al campo con aria-describedby y aria-invalid="true" |
| 3.3.2 Etiquetas o instrucciones (A) | placeholder como única etiqueta; formato de fecha no indicado | <label> real siempre, más texto de ayuda persistente para el formato |
| 3.3.3 Sugerencia ante errores (AA) | «Valor no válido» sin más pistas | «La fecha de vencimiento debe tener el formato dd/mm/aaaa y no puede ser anterior a hoy» |
| 3.3.4 Prevención de errores (AA) | «Eliminar proyecto» borra en cascada 300 tareas sin confirmación | Confirmación explícita, o acción reversible con posibilidad de deshacer |
| 4.1.2 Nombre, función, valor (A) | Acordeón de filtros sin aria-expanded; interruptor propio sin estado; pestañas sin roles | Rol correcto y estado sincronizado con el modelo en cada cambio |
| 4.1.3 Mensajes de estado (AA) | Aparece «Tarea guardada» y el contador «12 resultados», pero el lector no dice nada | Región activa con role="status" presente en el DOM antes de escribir el mensaje |
Conviértela en la plantilla de revisión de pull request del equipo. No hace falta auditar noventa criterios en cada cambio: si un pull request toca un formulario, se revisan 1.3.1, 3.3.1, 3.3.2, 3.3.3 y 4.1.3; si toca un componente interactivo, 2.1.1, 2.4.7 y 4.1.2; si toca los tokens de color, 1.4.1, 1.4.3 y 1.4.11. Cinco comprobaciones dirigidas valen más que una auditoría anual.
26.5 HTML semántico: el 70 % del problema sin escribir una línea de ARIA
Antes de cualquier atributo aria-* está el HTML. Y no es una cuestión de purismo: cada elemento semántico del HTML trae de fábrica un rol, un nombre calculado automáticamente, un comportamiento de teclado y una integración con la API de accesibilidad del sistema operativo que llevan más de dos décadas puliéndose en todos los navegadores y todos los lectores. Reimplementar eso con div y ARIA no es más flexible: es escribir a mano, con peor resultado, algo que ya tienes gratis.
26.5.1 Puntos de referencia de la página
Los puntos de referencia (o landmarks) son la tabla de contenidos estructural de la página. Los usuarios de lector de pantalla los usan constantemente para saltar entre zonas sin recorrer todo el contenido, con un atajo dedicado en todos los lectores. Cada elemento semántico de HTML mapea a un rol de punto de referencia sin que tengas que declararlo.
| Elemento HTML | Rol implícito | Cuántos por página | Uso en TaskFlow |
|---|---|---|---|
<header> (hijo directo de body) | banner | Uno | Barra superior con el logotipo y el menú de usuario |
<nav> | navigation | Varios, pero cada uno con nombre distinto | Menú lateral de proyectos, migas de pan, paginación |
<main> | main | Uno solo | El contenido de la ruta activa |
<aside> | complementary | Varios con nombre | Panel de detalle de la tarea seleccionada |
<footer> (hijo directo de body) | contentinfo | Uno | Versión de la aplicación, enlaces legales |
<search> | search | Normalmente uno | Buscador global de tareas (elemento reciente; comprueba el soporte o usa role="search") |
<form> con nombre accesible | form | Varios | Formulario de alta de tarea |
<section> con nombre accesible | region | Varios | Bloques del panel de control: «Vencen hoy», «Sin asignar» |
<section> del mundo
Un <section> solo se convierte en punto de referencia (region) si tiene nombre accesible, normalmente vía aria-labelledby apuntando a su encabezado o con aria-label. Sin nombre es un div con otro nombre: no aparece en la lista de regiones y no aporta nada. Lo mismo ocurre con <form>. Y al revés: si tienes tres <nav> sin nombre, el usuario oye «navegación, navegación, navegación» y no puede elegir.
<div class="app">
<div class="topbar">
<div class="logo">TaskFlow</div>
<div class="menu">
<div class="link" (click)="ir('/tareas')">Tareas</div>
<div class="link" (click)="ir('/equipo')">Equipo</div>
</div>
</div>
<div class="sidebar">
<div class="titulo">Proyectos</div>
<div class="proyecto" *ngFor="let p of proyectos">{{ p.nombre }}</div>
</div>
<div class="content">
<router-outlet />
</div>
</div>
<!-- Árbol de accesibilidad resultante: NADA.
Ni puntos de referencia, ni encabezados, ni enlaces, ni foco.
Un usuario de lector de pantalla no puede ni empezar. -->
<a class="salto-contenido" href="#contenido">Saltar al contenido</a>
<header>
<a routerLink="/" aria-label="TaskFlow, inicio">TaskFlow</a>
<nav aria-label="Principal">
<ul>
<li><a routerLink="/tareas" routerLinkActive="activo"
ariaCurrentWhenActive="page">Tareas</a></li>
<li><a routerLink="/equipo" routerLinkActive="activo"
ariaCurrentWhenActive="page">Equipo</a></li>
</ul>
</nav>
</header>
<nav aria-labelledby="tit-proyectos">
<h2 id="tit-proyectos">Proyectos</h2>
<ul>
<li *ngFor="let p of proyectos">
<a [routerLink]="['/proyectos', p.id]">{{ p.nombre }}</a>
</li>
</ul>
</nav>
<main id="contenido" tabindex="-1">
<router-outlet />
</main>
<footer><p>TaskFlow {{ version }}</p></footer>
Tres detalles de la versión correcta que no son casuales. El aria-label="Principal" distingue esa navegación de la de proyectos, así que la lista de puntos de referencia dice «navegación Principal» y «navegación Proyectos». La directiva ariaCurrentWhenActive de RouterLinkActive pone aria-current="page" en el enlace de la sección activa, que es la forma estándar de comunicar «estás aquí» —el color de fondo no lo comunica—. Y el tabindex="-1" en <main> existe porque el enlace de salto necesita un destino que pueda recibir el foco mediante programación; sin él, algunos navegadores mueven el scroll pero dejan el foco donde estaba.
26.5.2 Encabezados y jerarquía
Los encabezados son, con diferencia, el mecanismo de navegación más usado por los usuarios de lector de pantalla: mucho más que los puntos de referencia y muchísimo más que los enlaces de salto. Al entrar en una pantalla, el gesto habitual es pedir la lista de encabezados para entender la estructura de un vistazo, exactamente igual que una persona vidente recorre la página con la mirada.
- Un solo
<h1>por pantalla, que diga de qué va esa pantalla. En una aplicación de una sola página, eso significa que elh1cambia al cambiar de ruta: «Tareas de Proyecto Pagos», no «TaskFlow». - No saltes niveles al descender. Un
h4justo después de unh2le dice al usuario que se ha perdido un nivel intermedio y le hace buscar contenido que no existe. Subir varios niveles de golpe sí es correcto: cerrar una sección deh3y abrir el siguienteh2es lo normal. - El nivel no es un tamaño de letra. Es el único error importante aquí: se elige
h4«porque se ve bien». El nivel expresa la profundidad en el esquema del documento; el tamaño lo decides con CSS. - Los encabezados deben describir. Cinco
h2que dicen «Datos», «Datos», «Detalles»… no sirven de índice. El criterio 2.4.6 exige que encabezados y etiquetas sean descriptivos. - No uses un encabezado para dar énfasis a un texto que no encabeza nada, ni pongas texto suelto en un
<h3>por su estética.
<main id="contenido" tabindex="-1">
<h1>Tareas de Proyecto Pagos</h1> <!-- nivel 1: la pantalla -->
<section aria-labelledby="h-filtros">
<h2 id="h-filtros">Filtros</h2> <!-- nivel 2: bloque -->
<h3>Por estado</h3> <!-- nivel 3: subbloque -->
<h3>Por responsable</h3>
</section>
<section aria-labelledby="h-lista">
<h2 id="h-lista">Listado</h2>
<!-- Cada tarjeta de tarea lleva su propio encabezado de nivel 3.
Así el usuario puede saltar de tarea en tarea con la tecla de
encabezado de nivel 3 de su lector, sin tabular por los botones. -->
<article *ngFor="let t of tareas()">
<h3>{{ t.titulo }}</h3>
<p>{{ t.descripcion }}</p>
</article>
</section>
</main>
<!-- Lo que oye el usuario al pedir la lista de encabezados:
h1 Tareas de Proyecto Pagos
h2 Filtros
h3 Por estado
h3 Por responsable
h2 Listado
h3 Revisar contrato con el proveedor
h3 Migrar la pasarela de pago
Un índice completo de la pantalla en dos segundos. -->Un componente <app-tarjeta-tarea> no puede saber a qué profundidad se está usando: en la lista es un h3, pero dentro de un panel de un acordeón podría ser un h4. Codificar el nivel dentro del componente garantiza que en algún sitio saltará la jerarquía. Hay dos soluciones limpias. La primera y preferida: una entrada obligatoria nivel que el componente usa para renderizar el encabezado adecuado, de modo que quien lo coloca decide. La segunda, para casos genéricos: un role="heading" con aria-level calculado. Existió una propuesta de encabezados con nivel automático en HTML que nunca se implementó de forma fiable; no cuentes con ella.
26.5.3 Listas y tablas de datos
Las listas parecen un detalle irrelevante y no lo son: cuando un lector encuentra una <ul> anuncia «lista de 12 elementos» y, dentro, «elemento 3 de 12». Esa información de cardinalidad y posición es enormemente valiosa y desaparece por completo si la lista de tareas son doce <div> hermanos. El usuario no sabe cuántas tareas hay ni por dónde va.
Con las tablas la diferencia es aún más brutal. Una tabla de datos correcta permite al lector anunciar, en cada celda, a qué columna y a qué fila pertenece. Sin cabeceras declaradas, el usuario oye una secuencia de valores sueltos: «Revisar contrato, Alta, 12/09/2026, Marta, En curso» y tiene que recordar el orden de las columnas de memoria mientras recorre cuarenta filas.
<!-- Tabla construida con divs: el rol de tabla no existe,
no hay filas, no hay celdas, no hay cabeceras. -->
<div class="tabla">
<div class="fila cabecera">
<div class="celda">Título</div>
<div class="celda">Prioridad</div>
<div class="celda">Vence</div>
</div>
<div class="fila" *ngFor="let t of tareas()">
<div class="celda">{{ t.titulo }}</div>
<div class="celda">{{ t.prioridad }}</div>
<div class="celda">{{ t.vence | date }}</div>
</div>
</div>
<!-- Y esta variante tampoco vale: tabla real,
pero la fila de cabecera usa td. -->
<table>
<tr><td><b>Título</b></td><td><b>Prioridad</b></td></tr>
</table>
<table>
<!-- caption: nombre accesible de la tabla. Se anuncia al entrar
y aparece en la lista de tablas del lector. -->
<caption>
Tareas de Proyecto Pagos
<span class="small muted">{{ tareas().length }} resultados</span>
</caption>
<thead>
<tr>
<th scope="col">Título</th>
<th scope="col">Prioridad</th>
<th scope="col">Vence</th>
<th scope="col"><span class="solo-lectores">Acciones</span></th>
</tr>
</thead>
<tbody>
<tr *ngFor="let t of tareas()">
<!-- scope="row": convierte el título en la cabecera de la fila,
así cada celda se anuncia con el nombre de la tarea. -->
<th scope="row">{{ t.titulo }}</th>
<td>{{ t.prioridad }}</td>
<td>
<time [attr.datetime]="t.vence | date:'yyyy-MM-dd'">
{{ t.vence | date:'dd/MM/yyyy' }}
</time>
</td>
<td>
<button type="button" [attr.aria-label]="'Editar ' + t.titulo">
Editar
</button>
</td>
</tr>
</tbody>
</table>
scopeno es decorativo.scope="col"asocia la cabecera a toda su columna;scope="row", a toda su fila. Con ambos, el lector puede anunciar «Prioridad, Alta, fila Revisar contrato» en una sola celda.- Una tabla, un propósito. Si la usas para maquetar, ponle
role="presentation"para eliminar su semántica. En correo electrónico esto es habitual y necesario (sección 26.15). - Cabeceras complejas (dos filas de cabecera, celdas que abarcan varias columnas) requieren
iden las cabeceras yheadersen las celdas. Es laborioso y falla con facilidad: si puedes, divide la tabla en varias más simples. - La columna de acciones necesita cabecera. Una
<th>vacía deja al usuario sin saber en qué columna está; pon un texto oculto. - Si la tabla tiene scroll horizontal en pantallas estrechas, su contenedor necesita
tabindex="0"y un nombre accesible para que un usuario de teclado pueda desplazarla.
26.5.4 Por qué un <div> no sustituye a un <button>
Este es el fallo más común y el más ilustrativo, porque permite enumerar exactamente lo que un elemento nativo aporta. Cuando escribes <div (click)="borrar()">Borrar</div> no estás renunciando a «un poco de semántica»: estás renunciando a once comportamientos distintos, cada uno de los cuales tendrías que reimplementar.
| Lo que pierdes | Qué significa para el usuario | Qué tendrías que escribir para recuperarlo |
|---|---|---|
| Foco | El elemento no aparece al tabular: es inalcanzable sin ratón | tabindex="0" |
| Activación con Enter | La tecla habitual no hace nada | Manejador de keydown filtrando la tecla |
| Activación con Espacio | La otra tecla habitual tampoco; además Espacio hace scroll de la página | Otro manejador, más preventDefault() para no desplazar |
Rol button | El lector no dice «botón»: el usuario no sabe que se puede activar | role="button" |
| Aparecer en la lista de botones | El atajo «listar botones» del lector no lo encuentra | Consecuencia del rol anterior |
| Estado deshabilitado | disabled no existe en un div: el usuario puede activar algo inoperativo | aria-disabled + comprobación manual en cada manejador |
Pseudoclases :disabled, :enabled | El CSS de estado deja de funcionar | Clases propias y disciplina para mantenerlas |
| Envío de formulario | Dentro de un <form>, Enter ya no envía | Manejador de keydown en el formulario |
| Menú contextual del navegador | Se pierden las opciones nativas del elemento | No se puede recuperar |
| Estilos del modo de contraste forzado | En los temas de contraste del sistema, el botón deja de verse como un botón | Reglas específicas en @media (forced-colors: active) |
| Compatibilidad con control por voz y táctil | «Pulsa Borrar» no funciona; el gesto de doble toque de VoiceOver puede no activarlo | Depende del lector; no siempre es posible |
<!-- 1. Invisible para el teclado y para el lector -->
<div class="btn" (click)="borrar(t)">Borrar</div>
<!-- 2. El intento «arreglado» sigue estando mal: Espacio hace
scroll, no hay estado deshabilitado y el usuario del modo
de contraste forzado no ve ningún botón. -->
<div class="btn" role="button" tabindex="0"
(click)="borrar(t)"
(keydown.enter)="borrar(t)">Borrar</div>
<!-- 3. Enlace usado como botón: se anuncia "enlace", se abre
en pestaña nueva con Ctrl y ensucia el historial. -->
<a href="#" (click)="borrar(t)">Borrar</a>
<!-- 4. Botón sin type dentro de un form: envía el formulario -->
<form><button (click)="filtrar()">Filtrar</button></form>
<!-- Un elemento nativo. Todo lo demás sale gratis. -->
<button type="button" class="btn" (click)="borrar(t)">
Borrar
</button>
<!-- Con estado durante la petición: aria-disabled mantiene el
botón enfocable y anunciable como no disponible. -->
<button type="button" class="btn"
[attr.aria-disabled]="borrando() ? 'true' : null"
(click)="borrando() || borrar(t)">
{{ borrando() ? 'Borrando…' : 'Borrar' }}
</button>
<!-- Navegación = enlace. Acción = botón. Sin excepciones. -->
<a [routerLink]="['/tareas', t.id]">Ver detalle</a>
<!-- type explícito siempre: el defecto es "submit" -->
<form (ngSubmit)="filtrar()">
<button type="submit">Filtrar</button>
<button type="button" (click)="limpiar()">Limpiar</button>
</form>
Si al activarlo cambia la URL, es un enlace (<a href>). Si hace algo —abrir un diálogo, guardar, borrar, desplegar, ordenar—, es un botón. No es una preferencia estilística: determina lo que el lector anuncia, si el usuario puede abrirlo en una pestaña nueva, si funciona con Espacio o con Enter y si ensucia el historial de navegación. Un enlace estilizado como botón está bien; un enlace que no navega, no. Y href="#" con preventDefault() es siempre un error: en el mejor caso mueve el scroll, en el peor rompe el botón «atrás».
Si aun así necesitas construir un control interactivo sobre un elemento no semántico —porque el diseño exige que toda una tarjeta sea pulsable, por ejemplo—, la solución correcta no es poner role="button" a la tarjeta. Es poner un <button> o un <a> real dentro, con el nombre correcto, y extender su área de pulsación con un pseudoelemento absoluto que cubra la tarjeta. Así el elemento enfocable y anunciable es el nativo, y la zona sensible al ratón es toda la tarjeta.
@Component({
selector: 'app-tarjeta-tarea',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<article class="tarjeta">
<h3>
<a class="tarjeta__enlace" [routerLink]="['/tareas', tarea().id]">
{{ tarea().titulo }}
</a>
</h3>
<p>{{ tarea().descripcion }}</p>
<!-- Este botón debe quedar POR ENCIMA de la capa del enlace,
o sería inalcanzable con el ratón. -->
<button type="button" class="tarjeta__accion"
[attr.aria-label]="'Marcar ' + tarea().titulo + ' como hecha'"
(click)="completar.emit(tarea().id)">
<svg aria-hidden="true" focusable="false">…</svg>
</button>
</article>`,
styles: `
.tarjeta { position: relative; }
/* El enlace real conserva rol, foco y teclado; su área crece. */
.tarjeta__enlace::after {
content: ''; position: absolute; inset: 0;
}
/* El foco se dibuja en la TARJETA, no en el texto del enlace:
:focus-within propaga el estado al contenedor. */
.tarjeta:focus-within {
outline: 3px solid var(--color-foco);
outline-offset: 2px;
}
.tarjeta__enlace:focus-visible { outline: none; } /* ya lo pinta la tarjeta */
.tarjeta__accion { position: relative; z-index: 1; }
`,
})
export class TarjetaTareaComponent {
readonly tarea = input.required<Tarea>();
readonly completar = output<string>();
}26.6 ARIA: la primera regla es no usar ARIA
ARIA (Accessible Rich Internet Applications) es una especificación del W3C que define un vocabulario de atributos para describir roles, estados y propiedades que el HTML no puede expresar. Nació a mediados de la década de 2000, cuando las aplicaciones web empezaron a construir widgets complejos —árboles, rejillas editables, menús— que no tenían equivalente en el HTML de la época. Es una pieza imprescindible… y la herramienta más mal usada de todo el desarrollo web.
La razón es que ARIA tiene una propiedad contraintuitiva: no hace nada. No añade comportamiento, no captura teclas, no mueve el foco, no cambia el aspecto. Lo único que hace es modificar lo que el navegador publica en el árbol de accesibilidad. Es una promesa que haces al usuario, y si el comportamiento real no la cumple, has empeorado la situación: antes el usuario no sabía qué era aquello; ahora cree que es un menú que responde a las flechas, y no responde.
Esta frase está en la propia especificación y conviene tomarla literalmente. Un <div> sin nada es un elemento que el lector ignora: el usuario pasa de largo y no pierde nada más. Un <div role="button"> sin tabindex es un elemento que el lector anuncia como botón y que el usuario no puede alcanzar ni activar: le has prometido una funcionalidad inexistente y le has hecho perder tiempo intentándolo. Lo mismo con un role="tablist" que no responde a las flechas o un aria-expanded que nunca cambia de valor.
26.6.1 Las cinco reglas de uso de ARIA
La especificación de prácticas de uso enuncia cinco reglas. Están ordenadas por importancia y la primera es la que se incumple siempre:
- Si puedes usar un elemento o atributo nativo de HTML con la semántica y el comportamiento que necesitas, úsalo en lugar de reutilizar un elemento y añadirle ARIA. Esta regla resuelve la mayoría de los casos por sí sola.
- No cambies la semántica nativa salvo que sea imprescindible.
<h2 role="tab">destruye el encabezado: pierdes la navegación por encabezados. Envuelve o anida en lugar de sobrescribir. - Todo control interactivo debe ser operable con teclado. Si añades
role="button", tienes que añadir foco y manejadores de Enter y Espacio. - No pongas
role="presentation"niaria-hidden="true"en un elemento enfocable. Produce el peor estado posible: un elemento que recibe el foco pero que el lector no puede anunciar. El usuario tabula y oye silencio. - Todo elemento interactivo debe tener un nombre accesible. Sin nombre, el rol por sí solo no basta: «botón» a secas no dice qué hace.
¿NECESITO ARIA? · ÁRBOL DE DECISIÓN
┌───────────────────────────────────────────────┐
│ ¿Existe un elemento HTML que ya haga esto? │
└───────────────┬───────────────────────────────┘
SÍ ──────┤ │────── NO
▼ ▼
┌───────────────────────────┐ ┌────────────────────────────────────┐
│ ÚSALO. No añadas ARIA. │ │ ¿Es una COMBINACIÓN de nativos? │
│ button, a, input, select, │ │ (p. ej. details+summary para un │
│ details, dialog, table, │ │ acordeón; input+datalist para un │
│ fieldset, progress, meter │ │ autocompletado sencillo) │
└───────────────────────────┘ └────────┬───────────────┬───────────┘
SÍ ────┤ │──── NO
▼ ▼
┌──────────────────────────┐ ┌────────────────────────────┐
│ COMBÍNALOS. Casi siempre │ │ ¿Existe el patrón en la │
│ es la mejor opción. │ │ guía de authoring (APG)? │
└──────────────────────────┘ └───────┬────────────┬───────┘
SÍ ────┤ │──── NO
▼ ▼
┌───────────────────────────┐ ┌──────────────────────┐
│ IMPLEMENTA EL PATRÓN │ │ PARA. Rediseña la │
│ COMPLETO: roles + estados │ │ interacción con │
│ + TODAS las teclas. │ │ piezas conocidas. │
│ A medias = roto. │ │ Inventar un widget │
│ │ │ nuevo es un proyecto │
│ Antes de escribirlo: │ │ de meses, no una │
│ ¿lo tiene ya el CDK o │ │ tarea de un sprint. │
│ Angular Material? (26.13) │ │ │
└───────────────────────────┘ └──────────────────────┘
ATAJO PRÁCTICO: en una aplicación de gestión normal, el ARIA legítimo
se reduce a aria-label / aria-labelledby / aria-describedby /
aria-live / aria-expanded / aria-current / aria-invalid / aria-hidden.
Si tu plantilla tiene roles exóticos, sospecha del diseño antes que del código.
26.6.2 aria-label, aria-labelledby y texto visible
Ya vimos en 26.3.2 que hay una jerarquía de precedencia. Aquí va el criterio de elección, que es lo que se pregunta en cada revisión de código:
| Mecanismo | Cuándo usarlo | Ventajas | Riesgos |
|---|---|---|---|
Texto visible (contenido del elemento o <label for>) | Siempre que sea posible. Es la primera opción, no la última. | Lo ven todos los usuarios; funciona con control por voz; se traduce con el resto del contenido; no se desincroniza | Ninguno |
aria-labelledby | Cuando el nombre ya existe visible en otro sitio de la página: el encabezado de una sección, el título de un diálogo, el título de una fila | Una sola fuente de verdad; permite concatenar varios id; se traduce solo | Los id deben ser únicos en todo el documento y existir en el DOM; en listas hay que generarlos por elemento |
aria-label | Solo cuando no hay ni puede haber texto visible: botones de icono, distinguir dos navegaciones, nombrar una región sin encabezado | Directo y sin dependencias de id | Invisible: se olvida al refactorizar, se queda sin traducir, y si el elemento tiene texto lo pisa (rompe 2.5.3). Además, en algunos navegadores no se traduce con la traducción automática de la página |
Texto oculto visualmente (.solo-lectores) | Cuando necesitas complementar un texto visible corto: «Editar (tarea Revisar API)» | Es texto real: se traduce, se copia, funciona en braille y sobrevive a cualquier navegador | Hay que mantener la clase CSS correcta; si la implementas con display:none desaparece del árbol |
<!-- 1. Texto visible: lo mejor. Nada que añadir. -->
<button type="button" (click)="guardar()">Guardar tarea</button>
<!-- 2. labelledby: el nombre del diálogo YA está en su h2 -->
<div role="dialog" aria-modal="true" aria-labelledby="dlg-tit" aria-describedby="dlg-desc">
<h2 id="dlg-tit">Eliminar tarea</h2>
<p id="dlg-desc">Esta acción no se puede deshacer.</p>
</div>
<!-- 3. label: no hay texto visible posible y no debe haberlo -->
<button type="button" aria-label="Cerrar diálogo">
<svg aria-hidden="true" focusable="false">…</svg>
</button>
<!-- 4. Texto oculto: complementa el visible SIN pisarlo.
Nombre resultante: "Editar tarea Revisar API". -->
<button type="button">
Editar<span class="solo-lectores"> tarea {{ t.titulo }}</span>
</button>
<!-- 5. labelledby COMPUESTO: el propio botón primero, luego la fila.
El navegador concatena en el orden de los ids. -->
<th scope="row" [id]="'fila-' + t.id">{{ t.titulo }}</th>
<td>
<button type="button" [id]="'del-' + t.id"
[attr.aria-labelledby]="'del-' + t.id + ' fila-' + t.id">
Eliminar
</button>
</td>
<!-- 6. Distinguir dos regiones del mismo tipo -->
<nav aria-label="Principal">…</nav>
<nav aria-label="Migas de pan">…</nav>aria-label no funciona en cualquier elemento
Solo tiene efecto en elementos con un rol que admita nombre desde el autor. En un <div>, un <span> o un <p> sin rol, aria-label se ignora en la mayoría de los navegadores y lectores: pones el atributo, la auditoría automática no protesta y el usuario no oye nada. Si necesitas nombrar un contenedor genérico, dale primero un rol adecuado (region, group, img) o usa el elemento semántico correspondiente.
26.6.3 aria-describedby: la descripción accesible
Mientras el nombre identifica, la descripción amplía. Se anuncia después del nombre, normalmente tras una breve pausa, y en muchos lectores se puede silenciar por configuración. Eso define exactamente para qué sirve y para qué no: información complementaria útil pero no imprescindible para identificar el control.
- Sí: formato esperado de un campo («dd/mm/aaaa»), requisitos de una contraseña, mensaje de error de validación, aclaración de una acción destructiva, atajo de teclado asociado a un botón.
- No: el nombre del campo, información sin la cual la acción es ambigua, o textos larguísimos. Recuerda que se puede desactivar.
- Acepta varios
idseparados por espacios y los concatena. Es justo lo que necesitas para un campo que tiene a la vez un texto de ayuda permanente y un mensaje de error condicional. - El elemento referenciado puede estar oculto visualmente, pero no con
display: none: eso lo saca del árbol y la referencia queda vacía.
<label for="vence">Fecha de vencimiento</label>
<input id="vence" type="text" formControlName="vence"
autocomplete="off"
[attr.aria-invalid]="mostrarError() ? 'true' : null"
[attr.aria-describedby]="descripciones()">
<!-- Ayuda PERMANENTE: siempre presente en el DOM y siempre visible. -->
<p id="vence-ayuda" class="ayuda">Formato dd/mm/aaaa. No puede ser anterior a hoy.</p>
<!-- Error CONDICIONAL. Ojo: si lo quitas del DOM con @if, el id
desaparece; por eso describedby se recalcula (ver la clase). -->
<p id="vence-error" class="error" *ngIf="mostrarError()">
<svg aria-hidden="true" focusable="false" class="i-alerta"></svg>
La fecha debe tener el formato dd/mm/aaaa.
</p>
<!-- Lo que se oye al enfocar con error:
"Fecha de vencimiento, edición, no válido,
Formato dd/mm/aaaa. No puede ser anterior a hoy.
La fecha debe tener el formato dd/mm/aaaa."
Nombre → rol → estado → descripciones, en ese orden. -->export class CampoFechaComponent {
readonly control = input.required<FormControl<string>>();
readonly enviado = input(false);
/** Un error se muestra cuando es inválido y el usuario ya salió del
* campo o ya intentó enviar (mismo criterio que el capítulo 5). */
readonly mostrarError = computed(() => {
const c = this.control();
return c.invalid && (c.touched || this.enviado());
});
/** aria-describedby SOLO puede citar ids que existan en el DOM.
* Una referencia a un id inexistente no es "vacía": en algunos
* lectores anula la descripción entera, incluida la ayuda válida. */
readonly descripciones = computed(() =>
['vence-ayuda', this.mostrarError() ? 'vence-error' : null]
.filter((x): x is string => x !== null)
.join(' '));
}26.6.4 aria-live y las regiones activas
Una región activa es un contenedor cuyo contenido, al cambiar, se anuncia automáticamente sin mover el foco. Es la única forma de comunicar a un usuario de lector de pantalla algo que ha ocurrido en un sitio de la pantalla donde no está trabajando: «Tarea guardada», «12 resultados», «Se ha perdido la conexión».
| Valor / rol | Comportamiento | Cuándo usarlo | Cuándo NO |
|---|---|---|---|
aria-live="off" | No se anuncia (valor por defecto) | Para desactivar temporalmente una región activa | — |
aria-live="polite"role="status" | Espera a que el lector termine lo que está diciendo y entonces anuncia | El 95 % de los casos. Confirmaciones, contadores de resultados, «guardado automáticamente», fin de carga | Cuando el usuario debe reaccionar de inmediato |
aria-live="assertive"role="alert" | Interrumpe lo que el lector esté diciendo | Solo lo urgente e inesperado: sesión a punto de caducar, error que bloquea el envío, pérdida de datos | Confirmaciones normales. Abusar de assertive es como poner todos los logs en nivel error: el usuario deja de poder trabajar |
role="log" | Cortés, con semántica de secuencia: lo nuevo se añade al final | Chat, consola de actividad, historial de una tarea | Mensajes puntuales |
aria-atomic="true" | Anuncia la región completa, no solo el trozo que cambió | Mensajes con contexto: «3 de 12 tareas seleccionadas» | Logs largos: releería todo cada vez |
aria-busy="true" | Suspende los anuncios mientras se reconstruye la región | Actualizaciones en varios pasos: ponlo antes, quítalo al terminar | Cambios atómicos |
aria-live «no funcione»
El contenedor con aria-live tiene que existir en el DOM y estar vacío antes de que aparezca el mensaje. El navegador solo observa los cambios de las regiones activas que ya conocía; si insertas el contenedor y su texto en la misma operación, la mayoría de los lectores no anuncian nada. Con @if (mensaje()) alrededor del <div aria-live>, el resultado es silencio absoluto, y como el texto sí aparece en pantalla, el desarrollador jura que el código es correcto. La solución: el contenedor siempre presente, el @if por dentro. Y si el mensaje puede repetirse idéntico (dos veces «Guardado»), no cambia el texto y no se anuncia la segunda vez: añade un identificador incremental o límpialo antes.
<!-- 1. La región nace junto con el mensaje: silencio. -->
@if (mensaje()) {
<div role="status">{{ mensaje() }}</div>
}
<!-- 2. assertive para una confirmación trivial: interrumpe
al usuario en mitad de una frase. -->
<div aria-live="assertive">{{ ultimoGuardado() }}</div>
<!-- 3. Región activa que envuelve TODA la tabla: cada
recarga lee las cuarenta filas en voz alta. -->
<div aria-live="polite">
<table>…40 filas…</table>
</div>
<!-- 4. El mismo texto dos veces no se anuncia la segunda. -->
<div role="status">{{ 'Guardado' }}</div>
<!-- 1. Contenedor SIEMPRE en el DOM; el @if va dentro. -->
<div role="status" aria-atomic="true" class="solo-lectores">
@if (mensaje()) { {{ mensaje() }} }
</div>
<!-- 2. polite para lo normal; assertive solo para lo urgente. -->
<div role="status" class="solo-lectores">{{ ultimoGuardado() }}</div>
<div role="alert" class="aviso-critico">
@if (sesionCaduca()) { La sesión caducará en 2 minutos. }
</div>
<!-- 3. Se anuncia el RESUMEN, no los datos. La tabla queda
fuera de la región y se marca ocupada al recargar. -->
<div role="status" class="solo-lectores">
@if (!cargando()) { {{ tareas().length }} tareas encontradas }
</div>
<table [attr.aria-busy]="cargando() ? 'true' : null">…</table>
<!-- 4. Mensajes repetidos: añade una marca que los diferencie
o usa LiveAnnouncer del CDK, que gestiona esto por ti. -->
<div role="status" class="solo-lectores">{{ aviso()?.texto }}</div>
26.6.5 aria-expanded y aria-current
Son los dos estados que más se olvidan y los que producen fallos más silenciosos, porque todo parece funcionar.
aria-expanded va en el control que despliega, nunca en el panel desplegado. Su valor debe estar enlazado al mismo estado que gobierna la visibilidad: si están en dos sitios distintos, se desincronizarán. Sin él, el usuario no sabe si el acordeón está abierto ni si su pulsación ha tenido efecto: pulsa, oye silencio y vuelve a pulsar, cerrándolo.
aria-current marca el elemento actual dentro de un conjunto. Sus valores son un conjunto cerrado —page, step, location, date, time, true— y hay que elegir el correcto: page para navegación, step para un asistente por pasos, date para el día seleccionado en un calendario. Es el sustituto semántico del «lo pinto de otro color».
@Component({
selector: 'app-filtros-acordeon',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h3>
<!-- El botón va DENTRO del encabezado: así el usuario puede
navegar por encabezados y activar desde ahí. Regla 2 de
ARIA: no pongas role="button" en el propio h3. -->
<button type="button"
[attr.aria-expanded]="abierto()"
[attr.aria-controls]="panelId"
(click)="abierto.set(!abierto())">
Filtros avanzados
<svg class="chevron" aria-hidden="true" focusable="false">…</svg>
</button>
</h3>
<!-- El panel se oculta con [hidden], no se destruye. Así el id
referenciado por aria-controls existe siempre y no se pierde
el estado de los campos al plegar. -->
<div [id]="panelId" [hidden]="!abierto()">
<ng-content />
</div>`,
})
export class FiltrosAcordeonComponent {
/** UNA sola fuente de verdad: la señal alimenta a la vez el
* atributo aria-expanded y la visibilidad real. Imposible que
* se desincronicen, que es el fallo clásico de este widget. */
readonly abierto = signal(false);
protected readonly panelId = `panel-${crypto.randomUUID()}`;
}<details> y <summary>
Para un acordeón simple, <details><summary> te da gratis el estado expandido, el teclado, el rol y la persistencia del contenido, sin una línea de ARIA ni de TypeScript. Aplicando la primera regla, es la opción correcta salvo que necesites animación de apertura controlada, comportamiento de acordeón exclusivo o control total del marcado interno. Comprueba el estilado del marcador y el comportamiento de name para acordeones exclusivos según la versión de navegador que soportes.
26.6.6 aria-hidden y el error clásico de ocultar algo enfocable
aria-hidden="true" poda un nodo y toda su descendencia del árbol de accesibilidad, sin afectar en absoluto a lo que se ve. Sus usos legítimos son pocos y concretos:
- Iconos decorativos que acompañan a un texto que ya dice lo mismo. Un
<svg>junto a la palabra «Guardar» solo añadiría ruido. - Duplicados visuales: el mismo dato mostrado dos veces por diseño (un valor en la barra de progreso y en su etiqueta).
- Contenido fuera de pantalla que sigue en el DOM: los paneles inactivos de un carrusel, la parte del listado ya desplazada en una animación.
- El resto de la página mientras hay un diálogo modal abierto, aunque para esto el atributo
inertes hoy la herramienta correcta, porque además desactiva el foco y los eventos de puntero.
Es el fallo que axe detecta con la regla «un elemento con aria-hidden no debe ser enfocable ni contener elementos enfocables», y produce el peor comportamiento posible: el usuario pulsa Tab, el foco entra en el elemento y el lector no dice nada. Ni el nombre, ni el rol, ni el estado. El usuario está atrapado en un punto del que no sabe nada: no sabe qué es, no sabe si al pulsar Enter guardará o borrará, y no sabe cuántas veces más tiene que tabular. Si algo no debe ser leído, tampoco debe ser alcanzable: usa inert, hidden, display: none o quítalo del DOM.
<!-- 1. El panel está fuera de pantalla y "oculto" a ARIA,
pero sus botones siguen recibiendo foco. Al tabular:
silencio absoluto en 6 paradas. -->
<div class="panel fuera-de-pantalla" aria-hidden="true">
<button type="button">Aplicar</button>
<a routerLink="/ayuda">Ayuda</a>
</div>
<!-- 2. aria-hidden en el propio botón: enfocable y mudo. -->
<button type="button" aria-hidden="true" (click)="cerrar()">
Cerrar
</button>
<!-- 3. Icono con aria-hidden que era el ÚNICO contenido:
el botón se queda sin nombre accesible. -->
<button type="button">
<svg aria-hidden="true">…</svg>
</button>
<!-- 1. inert: quita el foco, los eventos de puntero y la
semántica de golpe. Es la respuesta moderna. -->
<div class="panel" [attr.inert]="cerrado() ? '' : null">
<button type="button">Aplicar</button>
<a routerLink="/ayuda">Ayuda</a>
</div>
<!-- 2. Si no debe usarse, quítalo del DOM o deshabilítalo;
no lo dejes enfocable y mudo. -->
@if (sePuedeCerrar()) {
<button type="button" (click)="cerrar()">Cerrar</button>
}
<!-- 3. El icono se oculta y el nombre lo aporta otra cosa. -->
<button type="button" aria-label="Cerrar panel">
<svg aria-hidden="true" focusable="false">…</svg>
</button>
26.7 Teclado: el denominador común de toda la accesibilidad
Si tuvieras que elegir una sola cosa que probar en una aplicación, elige el teclado. Es el canal de entrada que usan los lectores de pantalla, los conmutadores, el control por voz, el seguimiento ocular y los teclados en pantalla; y también el usuario avanzado que rellena formularios sin soltar las manos. Una aplicación completamente operable con teclado ya cumple gran parte de los criterios del principio «operable» y, de paso, es una aplicación mejor para todos.
26.7.1 Orden de tabulación: no lo toques
El navegador construye el orden de tabulación siguiendo el orden del DOM entre los elementos enfocables. Esa regla, que parece limitante, es en realidad la garantía de que el orden es coherente. Los tres valores posibles de tabindex tienen usos muy distintos:
| Valor | Efecto | Cuándo |
|---|---|---|
Sin tabindex | Los elementos interactivos nativos son enfocables; el resto, no | Lo normal. Aspira a que sea el 99 % de tu código |
tabindex="0" | Añade el elemento al orden natural, en su posición del DOM | Un contenedor con scroll que debe poder desplazarse con teclado; el elemento raíz de un widget compuesto (una rejilla, un árbol) |
tabindex="-1" | Enfocable solo mediante programación (el.focus()), fuera del orden de tabulación | Destino de un enlace de salto; <main> al cambiar de ruta; los elementos no activos de un widget con foco gestionado; un contenedor al que quieres llevar el foco tras eliminar una fila |
tabindex="1" o mayor | Se coloca antes de todos los elementos de orden natural, ordenados entre sí por su número | Nunca. Sin excepciones prácticas |
tabindex positivo es siempre un error
Un solo tabindex="1" en la página reordena el recorrido completo: ese elemento pasa a ser el primero en recibir el foco, por delante de la barra de navegación y del enlace de salto. En cuanto hay dos o tres, mantenerlos coherentes exige conocer todos los valores de toda la aplicación a la vez, incluidos los que introduzca un componente de terceros o un diálogo que se abre. Es una variable global de las peores: acoplada, invisible y sin comprobación. El arreglo correcto nunca es «ajustar los números», es reordenar el DOM para que coincida con el orden visual. Y si el orden visual no se puede conseguir sin desordenar el DOM, el problema está en el CSS: cuidado con order de flexbox, con grid-area y con float, porque desacoplan lo que se ve de lo que se tabula e incumplen el criterio 2.4.3.
26.7.2 El indicador de foco visible
Vamos a decirlo sin rodeos: outline: none sin sustituto es probablemente la peor línea de CSS que se puede escribir. No es un fallo de accesibilidad menor; es dejar sin usar la aplicación a cualquiera que navegue con teclado, porque el usuario deja de saber dónde está. Imagina usar un ratón cuyo cursor fuera invisible: pulsas y ocurre algo, en algún sitio.
Aparece siempre por el mismo motivo: el contorno por defecto del navegador es feo y aparecía también al hacer clic con el ratón. Ese segundo problema está resuelto desde que existe :focus-visible, que aplica el estilo solo cuando el navegador considera que el usuario se beneficia de verlo —al tabular sí, al pulsar un botón con el ratón normalmente no—.
/* 1. El clásico. Deja la aplicación inutilizable con teclado. */
*:focus { outline: none; }
/* 2. Variante disfrazada: el reset "moderno" copiado de un blog. */
button, a, input { outline: 0; box-shadow: none; }
/* 3. Sustituto insuficiente: 1 píxel de un gris claro sobre
fondo blanco no llega a 3:1 (incumple 1.4.11). */
:focus { outline: 1px solid #d0d0d0; }
/* 4. Solo cambia el color de fondo: invisible para quien no
distingue ese par de colores, y muy sutil. */
button:focus { background: #f7f7f7; }
/* 5. Sin offset: el contorno se pega al borde y se confunde
con el borde del propio control. */
:focus { outline: 2px solid blue; outline-offset: 0; }
/* Un único indicador para toda la aplicación, con contraste
suficiente y separado del control para que se vea siempre. */
:root {
--foco-color: #0b5fff; /* comprobado ≥ 3:1 sobre los fondos */
--foco-grosor: 3px;
}
:focus-visible {
outline: var(--foco-grosor) solid var(--foco-color);
outline-offset: 2px;
/* Doble anillo: garantiza visibilidad sobre fondos oscuros
y sobre fondos claros sin calcular caso por caso. */
box-shadow: 0 0 0 calc(var(--foco-grosor) + 2px) #fff;
border-radius: 3px;
}
/* Quitar el contorno del clic de ratón es LEGÍTIMO; quitar el
de teclado, no. Esto es exactamente esa distinción. */
:focus:not(:focus-visible) { outline: none; }
/* En modo de contraste forzado el color se ignora: usa la
palabra clave del sistema para el color del foco. */
@media (forced-colors: active) {
:focus-visible { outline-color: Highlight; }
}
/* 2.4.11: que una barra fija no tape el elemento enfocado. */
:root { scroll-padding-block: 5rem; }
Contraste. El indicador es un elemento no textual: le aplica el criterio 1.4.11 con su umbral de 3:1 frente a los colores adyacentes. Un halo del color de marca sobre un botón del color de marca no vale.
Grosor y separación. Un contorno de 1 píxel pegado al borde del control es fácil de perder de vista al tabular rápido. Dos o tres píxeles con outline-offset se ven sin esfuerzo. WCAG 2.2 incorporó además un criterio de apariencia del foco en nivel AAA que da buenas pautas de área mínima aunque no lo persigas.
No lo elimines dentro de componentes. El patrón de la sección 26.5.4 —dibujar el foco en la tarjeta con :focus-within y quitarlo del enlace interno— es correcto porque hay un indicador. Quitarlo del enlace sin poner nada, no.
Pruébalo con el zoom al 200 % y en modo oscuro. Un foco que funciona en el tema claro puede desaparecer en el oscuro si el color está fijado.
26.7.3 Enlace de salto al contenido
En TaskFlow, el menú lateral tiene los proyectos del usuario: pueden ser treinta enlaces. Sin un mecanismo de salto, un usuario de teclado los recorre en cada cambio de pantalla antes de llegar al contenido. El criterio 2.4.1 exige poder evitar esos bloques repetidos, y el enlace de salto es la técnica más simple y la más eficaz.
<!-- PRIMER elemento enfocable del documento, antes del header. -->
<a class="salto-contenido" href="#contenido">Saltar al contenido principal</a>
<a class="salto-contenido" href="#nav-proyectos">Saltar a la lista de proyectos</a>
<header>…</header>
<nav id="nav-proyectos" aria-labelledby="tit-proyectos">…</nav>
<!-- El destino necesita tabindex="-1": sin él, algunos navegadores
desplazan el scroll pero dejan el foco en el enlace, así que la
siguiente pulsación de Tab vuelve al menú. -->
<main id="contenido" tabindex="-1">…</main>/* Oculto hasta que recibe el foco. NO uses display:none:
dejaría de ser enfocable y el enlace no existiría. */
.salto-contenido {
position: absolute;
top: 0;
left: 0;
transform: translateY(-200%); /* fuera de la vista */
z-index: 1000;
padding: 0.75rem 1.25rem;
background: var(--color-fondo);
color: var(--color-texto);
border: 2px solid var(--foco-color);
border-radius: 0 0 4px 0;
}
/* Al enfocarlo, entra en pantalla. Sin transición si el usuario
ha pedido movimiento reducido (sección 26.11). */
.salto-contenido:focus {
transform: translateY(0);
}
/* El destino recibe el foco por programación: no queremos un
contorno permanente ahí, pero sí conviene marcarlo al llegar. */
main:focus { outline: none; }
main:focus-visible { outline: 3px solid var(--foco-color); }26.7.4 Atajos de teclado
Los atajos son una gran mejora de productividad y una fuente inagotable de conflictos. La regla básica: un atajo de una sola tecla, sin modificador, es peligroso. Los usuarios de lector de pantalla y de reconocimiento de voz emiten pulsaciones de tecla constantemente; si tu aplicación interpreta N como «nueva tarea», cada vez que alguien dicte una palabra con «n» creará tareas. WCAG 2.1 añadió un criterio específico sobre atajos de una sola tecla (2.1.4) que exige poder desactivarlos, reasignarlos o limitarlos al elemento con foco.
- Usa modificadores (
Ctrl,Alt,Meta) para los atajos globales, y comprueba que no pisas los del navegador ni los del lector de pantalla. - Ámbito local siempre que puedas. Un atajo que solo funciona cuando el foco está dentro de la tabla de tareas no molesta a nadie.
- Documéntalos y hazlos descubribles: un diálogo de ayuda con la lista, accesible desde un botón, y
aria-keyshortcutsen los controles correspondientes para que el lector los anuncie. - Permite desactivarlos en las preferencias del usuario.
- Nunca captures teclas dentro de un campo de texto sin comprobar el destino del evento: Espacio y las letras pertenecen al usuario mientras escribe.
@Injectable({ providedIn: 'root' })
export class AtajosService {
private readonly doc = inject(DOCUMENT);
private readonly router = inject(Router);
private readonly preferencias = inject(PreferenciasService);
/** Elementos donde el usuario está escribiendo: sus teclas son suyas. */
private escribiendo(destino: EventTarget | null): boolean {
const el = destino as HTMLElement | null;
if (!el) return false;
const etiqueta = el.tagName;
return etiqueta === 'INPUT' || etiqueta === 'TEXTAREA'
|| etiqueta === 'SELECT' || el.isContentEditable;
}
registrar(): void {
fromEvent<KeyboardEvent>(this.doc, 'keydown')
.pipe(
filter(() => this.preferencias.atajosActivos()), // desactivables
filter((e) => !this.escribiendo(e.target)), // no pisar la escritura
filter((e) => !e.repeat), // ignorar autorrepetición
takeUntilDestroyed(),
)
.subscribe((e) => {
// Modificador obligatorio: nada de teclas sueltas globales.
if (!(e.altKey && !e.ctrlKey && !e.metaKey)) return;
switch (e.key.toLowerCase()) {
case 'n':
e.preventDefault();
this.router.navigate(['/tareas', 'nueva']);
break;
case 'b':
e.preventDefault();
this.doc.getElementById('busqueda')?.focus();
break;
case '?':
e.preventDefault();
this.abrirAyudaAtajos();
break;
}
});
}
}26.7.5 Patrones de interacción: el contrato de teclas
La guía de prácticas de autoría de ARIA (conocida por sus siglas inglesas, APG) documenta, para cada patrón de widget, los roles necesarios, los estados que hay que mantener y —lo más valioso— exactamente qué debe hacer cada tecla. No es una recomendación estética: es el comportamiento que el usuario ya conoce de su sistema operativo y que espera encontrar. Un menú que no responde a las flechas es tan sorprendente como un ascensor cuyos botones no se pulsan.
| Patrón | Roles | Teclas obligatorias | Detalle que casi todo el mundo olvida |
|---|---|---|---|
| Diálogo modal | dialog + aria-modal="true", nombre con aria-labelledby | Tab y Shift+Tab circulan dentro; Esc cierra | El foco entra al abrir y vuelve al elemento que lo abrió al cerrar. El fondo debe quedar inerte. |
| Pestañas | tablist, tab, tabpanel; aria-selected, aria-controls | Tab entra en la pestaña activa; ← → cambian de pestaña; Inicio/Fin primera y última | Solo la pestaña activa tiene tabindex="0"; las demás, -1. Es el patrón de «foco gestionado»: Tab no recorre las pestañas una a una, sale al panel. |
| Menú de acciones | Botón con aria-haspopup="true" y aria-expanded; menu y menuitem | Enter, Espacio o ↓ abren; ↑ ↓ recorren; Esc cierra y devuelve el foco al botón; letras hacen búsqueda incremental | Al abrir con ↓ el foco va al primer elemento; al abrir con ↑, al último. Un menu solo debe contener menuitem: no metas campos de texto dentro. |
| Combo con autocompletado | combobox en el campo, listbox en la lista, option; aria-expanded, aria-controls, aria-activedescendant | ↓ abre y baja; ↑ sube; Enter selecciona; Esc cierra sin seleccionar; Tab sale | El foco no se mueve nunca de la caja de texto. La opción «activa» se indica con aria-activedescendant. Mover el foco real a la lista rompe la escritura. Este patrón cambió bastante entre versiones de ARIA: sigue la guía vigente, no un tutorial antiguo. |
| Árbol | tree, treeitem, group; aria-expanded, aria-selected, aria-level | ↑ ↓ recorren los nodos visibles; → expande o baja al hijo; ← colapsa o sube al padre; Inicio/Fin; Enter activa | Foco gestionado como en las pestañas: un solo nodo con tabindex="0". → tiene doble comportamiento según si el nodo está ya expandido. |
| Interruptor | switch con aria-checked, o <input type="checkbox"> | Espacio alterna | Un switch no admite el estado mixed. Si necesitas indeterminado, usa checkbox. |
| Barra de herramientas | toolbar | Tab entra una sola vez; ← → recorren los botones | Reduce el número de paradas de Tab en pantallas con muchas acciones. Requiere gestión de foco. |
| Tabla ordenable | <th> con aria-sort y un <button> dentro | El botón responde a Enter y Espacio por ser nativo | aria-sort va en la <th>, no en el botón, y solo una columna lo lleva a la vez. El cambio de orden debe anunciarse en una región activa. |
Comprueba si el CDK de Angular o Angular Material ya lo resuelven (sección 26.13). Un diálogo, un menú, un conjunto de pestañas, un árbol y un autocompletado accesibles son semanas de trabajo bien hecho, no horas: hay que cubrir la gestión de foco, la búsqueda incremental, el comportamiento en modo lectura de los lectores de Windows, los gestos táctiles de VoiceOver, la dirección del texto de derecha a izquierda y el modo de contraste forzado. Reimplementarlo «porque el diseño es distinto» es casi siempre una mala decisión de ingeniería: extiende el estilo del componente existente.
26.8 Gestión del foco en una aplicación de una sola página
Aquí está el fallo más grave y menos conocido de las aplicaciones Angular, y no es culpa de Angular: es una consecuencia estructural del enrutado del lado del cliente. En una web tradicional, al pulsar un enlace el navegador carga un documento nuevo: anuncia el título de la página, reinicia el foco al principio del documento y el usuario de lector de pantalla sabe con certeza que ha cambiado de pantalla. En una aplicación de una sola página no ocurre nada de eso: el navegador no ha navegado, solo se ha reemplazado un trozo de DOM.
El resultado es demoledor. El usuario pulsa «Proyecto Pagos» en el menú, el lector se queda en silencio, el foco permanece en un enlace que ya no existe —Angular ha destruido ese nodo— y el navegador lo devuelve al <body>. Al pulsar Tab, el usuario aparece al principio de la página, sin saber si la navegación funcionó, si tardó, si falló o si sigue en la misma pantalla. Se ha probado en decenas de auditorías: la reacción típica es pulsar el enlace tres o cuatro veces más.
El router de Angular gestiona la restauración de la posición del scroll (con withInMemoryScrolling) y puede fijar el título del documento por ruta a partir de la propiedad title, que además cumple el criterio 2.4.2. Lo que no hace es mover el foco ni anunciar el cambio de pantalla: eso es responsabilidad de la aplicación. Comprueba la documentación de tu versión antes de dar por supuesta cualquier función adicional; esta es un área donde el framework ha ido añadiendo utilidades.
import { DOCUMENT, inject, Injectable } from '@angular/core';
import { LiveAnnouncer } from '@angular/cdk/a11y';
import { NavigationEnd, Router } from '@angular/router';
import { Title } from '@angular/platform-browser';
import { filter } from 'rxjs';
/**
* Restituye lo que el navegador hace gratis en una web multipágina:
* anunciar el cambio de pantalla y reubicar el foco.
* Se activa UNA vez, al arrancar la aplicación.
*/
@Injectable({ providedIn: 'root' })
export class NavegacionA11yService {
private readonly router = inject(Router);
private readonly doc = inject(DOCUMENT);
private readonly titulo = inject(Title);
private readonly locutor = inject(LiveAnnouncer);
iniciar(): void {
this.router.events
.pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd))
.subscribe(() => {
// 1. ANUNCIAR. 'polite' para no interrumpir; el título ya lo ha
// fijado el router a partir de la propiedad `title` de la ruta.
this.locutor.announce(`${this.titulo.getTitle()}. Página cargada.`, 'polite');
// 2. REUBICAR EL FOCO. El destino ideal es el h1 de la nueva
// pantalla; si no existe, el contenedor principal.
// setTimeout con 0: la vista de la ruta nueva aún no está
// en el DOM cuando se emite NavigationEnd.
setTimeout(() => {
const destino =
this.doc.querySelector<HTMLElement>('main h1') ??
this.doc.querySelector<HTMLElement>('main');
if (!destino) return;
// tabindex -1 temporal: enfocable por programación, pero sin
// añadir una parada permanente al orden de tabulación.
if (!destino.hasAttribute('tabindex')) {
destino.setAttribute('tabindex', '-1');
destino.addEventListener('blur', () => destino.removeAttribute('tabindex'),
{ once: true });
}
// preventScroll: el router ya ha restaurado la posición.
destino.focus({ preventScroll: true });
});
});
}
}Dos decisiones de este servicio merecen justificación. La primera: se enfoca el h1, no el <main> completo. Enfocar el contenedor hace que algunos lectores lean toda la pantalla de golpe, lo que es peor que el silencio. Enfocar el encabezado anuncia «Tareas de Proyecto Pagos, encabezado de nivel 1» y deja al usuario justo al principio del contenido, exactamente como en una navegación real. La segunda: se anuncia además de mover el foco, porque el anuncio confirma que la navegación terminó incluso si el foco tarda, y porque algunos lectores no verbalizan el elemento al recibir foco por programación.
26.8.1 Trampa de foco en diálogos
Un diálogo modal establece un contrato claro: mientras esté abierto, el resto de la aplicación no existe. Visualmente eso se comunica con un fondo oscurecido. Para el teclado hay que implementarlo: si el foco puede salir del diálogo, el usuario acaba tabulando por elementos que no ve, pulsando botones invisibles y sin forma de volver.
RECORRIDO DEL FOCO EN UN DIÁLOGO MODAL
ANTES DE ABRIR
─────────────────────────────────────────────────────────────
[Nueva tarea] [Filtrar] [Eliminar]◄── FOCO ← se GUARDA la referencia
┌─────────────────────────────────────────┐ a este elemento
│ tabla de tareas (40 filas enfocables) │
└─────────────────────────────────────────┘
AL ABRIR
─────────────────────────────────────────────────────────────
░░░░░░░░ resto de la página: inert ░░░░░░░░ ← sin foco, sin clic,
░░░░░░░░ (o aria-hidden="true") ░░░░░░░░ fuera del árbol
┌───────────────────────────────────────┐
│ role="dialog" aria-modal="true" │
│ aria-labelledby="dlg-tit" │
│ │
│ Eliminar tarea [ X ]◄─┐ │
│ Esta acción no se puede deshacer.│ │
│ │ │
│ [Cancelar] [Eliminar]─┘ │ ← FOCO INICIAL:
└───────────────────────────────────────┘ el primer enfocable,
o el más seguro
CICLO CERRADO (la "trampa")
─────────────────────────────────────────────────────────────
┌──────────────────────────────────┐
Tab ────►│ X → Cancelar → Eliminar ─┐ │
│ ▲ │ │
│ └─────────────────────────────┘ │ Tab en el ÚLTIMO
└──────────────────────────────────┘ vuelve al PRIMERO
Shift+Tab: el mismo ciclo en sentido inverso.
Esc: cierra.
AL CERRAR
─────────────────────────────────────────────────────────────
[Nueva tarea] [Filtrar] [Eliminar]◄── FOCO RESTITUIDO
al elemento guardado
Si no se restituye: el foco cae al <body> y el usuario
vuelve al principio de la página. Cerrar un diálogo NO
debe costar treinta tabulaciones.
Escribir esto a mano es más difícil de lo que parece: hay que calcular qué elementos son enfocables (una consulta CSS no basta, porque un elemento puede estar oculto por un ancestro), reaccionar a los cambios del contenido del diálogo, gestionar diálogos anidados y tratar el caso en que el elemento que abrió el diálogo ya no existe al cerrarlo. Por eso el CDK de Angular lo trae resuelto, y por eso el MatDialog de Material lo hace todo por defecto. Si tienes que construirlo, este es el esqueleto correcto:
@Component({
selector: 'app-dialogo',
imports: [A11yModule],
template: `
<!-- cdkTrapFocus crea la trampa; cdkTrapFocusAutoCapture mueve
el foco dentro al aparecer y lo DEVUELVE al destruirse. -->
<div class="dialogo" role="dialog" aria-modal="true"
[attr.aria-labelledby]="tituloId"
[attr.aria-describedby]="descripcionId"
cdkTrapFocus [cdkTrapFocusAutoCapture]="true"
(keydown.escape)="cerrar.emit()">
<h2 [id]="tituloId">{{ titulo() }}</h2>
<p [id]="descripcionId"><ng-content /></p>
<div class="acciones">
<!-- cdkFocusInitial: el foco va aquí, no al botón destructivo -->
<button type="button" cdkFocusInitial (click)="cerrar.emit()">Cancelar</button>
<button type="button" class="peligro" (click)="confirmar.emit()">
{{ textoConfirmar() }}
</button>
</div>
</div>`,
})
export class DialogoComponent {
readonly titulo = input.required<string>();
readonly textoConfirmar = input('Aceptar');
readonly cerrar = output<void>();
readonly confirmar = output<void>();
protected readonly tituloId = `dlg-tit-${crypto.randomUUID()}`;
protected readonly descripcionId = `dlg-desc-${crypto.randomUUID()}`;
}<dialog> nativo y por qué a veces no basta
El elemento <dialog> con showModal() resuelve de forma nativa la trampa de foco, el cierre con Esc, la inercia del fondo y el apilamiento en la capa superior. Es una gran opción para diálogos sencillos y debería ser tu primer candidato por la primera regla de ARIA. Los motivos legítimos para no usarlo son concretos: necesitas animaciones de entrada y salida con control fino, necesitas apilar diálogos con reglas propias, o ya usas el overlay del CDK para tooltips y menús y quieres un único sistema de capas. Ten en cuenta que showModal() no restituye el foco en todos los navegadores: guarda la referencia y devuélvela tú.
26.8.2 El foco tras eliminar un elemento de una lista
Caso cotidiano en TaskFlow: cuarenta filas, cada una con su botón «Eliminar». El usuario elimina la fila 17. Angular destruye ese nodo del DOM y, con él, el botón que tenía el foco. El navegador devuelve el foco al <body>: el usuario está de nuevo al principio de la página y tiene que recorrer diecisiete filas para seguir trabajando. Con tres borrados seguidos, la tarea se vuelve inviable.
La regla general es sencilla y aplica a cualquier destrucción de un elemento enfocado: decide siempre el destino del foco antes de destruir. El orden de preferencia es el elemento equivalente siguiente, si no el anterior, y si la lista queda vacía, el contenedor o el encabezado de la lista.
export class ListaTareasComponent {
private readonly host = inject(ElementRef<HTMLElement>);
private readonly locutor = inject(LiveAnnouncer);
private readonly api = inject(TareasApi);
readonly tareas = signal<Tarea[]>([]);
async eliminar(indice: number): Promise<void> {
const tarea = this.tareas()[indice];
await this.api.eliminar(tarea.id);
const restantes = this.tareas().filter((t) => t.id !== tarea.id);
this.tareas.set(restantes);
// Confirmación audible: el usuario no ve desaparecer la fila.
this.locutor.announce(`Tarea ${tarea.titulo} eliminada. ${restantes.length} tareas.`);
// Destino: siguiente → anterior → encabezado de la lista.
// afterNextRender garantiza que el DOM nuevo ya está pintado.
afterNextRender(() => {
const objetivo = restantes[indice] ?? restantes[indice - 1];
const selector = objetivo
? `[data-borrar="${objetivo.id}"]`
: '[data-lista-vacia]';
this.host.nativeElement.querySelector<HTMLElement>(selector)?.focus();
}, { injector: this.injector });
}
}El mismo razonamiento aplica a otros tres casos que aparecen constantemente y que casi nunca se tratan: al colapsar una sección cuyo contenido tenía el foco (devuélvelo al botón que la controla), al completar un asistente por pasos (foco en el encabezado del paso nuevo, y anúncialo), y al reemplazar una lista por un resultado de búsqueda (no muevas el foco, que el usuario sigue escribiendo, pero anuncia el número de resultados en una región activa).
26.9 Formularios accesibles a fondo
El formulario es donde se concentran más criterios de WCAG por metro cuadrado de interfaz y donde un fallo de accesibilidad tiene consecuencias más directas: si el usuario no puede completar el alta de tarea, no puede usar TaskFlow. Esta sección construye el formulario de tarea entero, primero como suele estar y después como debe estar, sobre los formularios reactivos del capítulo 5.
26.9.1 Etiquetas de verdad
- Todo campo necesita una
<label>asociada porforeid, o envolviendo al campo. La asociación explícita confores preferible: funciona con cualquier estructura de marcado y permite que al pulsar la etiqueta el foco vaya al campo, lo que amplía el área de pulsación (útil para 2.5.8). - El
placeholderno es una etiqueta. Desaparece al escribir —el usuario pierde la referencia justo cuando la necesita para revisar—, tiene contraste insuficiente por diseño en todos los navegadores, no siempre se anuncia y no es traducible por herramientas de terceros. Incumple 3.3.2 y a menudo 1.4.3. Sirve como ejemplo de formato, nunca como nombre. - La etiqueta flotante (el efecto de que el texto sube al enfocar) es aceptable si es una
<label>real que se mueve con CSS, no unplaceholderanimado. Angular Material lo hace bien. - Nunca uses
titlecomo etiqueta: no se ve con teclado, no se ve en táctil y algunos lectores lo ignoran.
26.9.2 Agrupación con fieldset y legend
Un grupo de botones de radio o de casillas plantea un problema específico: la etiqueta de cada opción dice «Alta», «Media», «Baja», pero ninguna dice de qué. Un usuario que llega al grupo tabulando oye «Alta, botón de radio, 1 de 3» y no sabe si está eligiendo la prioridad, la urgencia o el nivel de riesgo. El <fieldset> con su <legend> resuelve exactamente esto: el lector anuncia la leyenda al entrar en el grupo, y muchos la repiten con cada opción.
Es también el mecanismo correcto para agrupar campos relacionados (una dirección, un rango de fechas «desde/hasta») y para las secciones de un formulario largo. Alternativa cuando el marcado no admite fieldset: role="group" con aria-labelledby, que produce un resultado equivalente con menos garantías de soporte.
26.9.3 Errores: asociados, anunciados y en el momento correcto
Tres preguntas hay que responder bien, y son independientes entre sí:
- ¿Cuándo se valida? Nunca mientras el usuario escribe la primera vez: marcar en rojo un correo incompleto tras la tercera letra es hostil y confunde. El criterio del capítulo 5 es el correcto: mostrar el error cuando el control es inválido y el usuario ya salió del campo o ya intentó enviar. En la corrección sí conviene validar en vivo, para que el error desaparezca en cuanto se arregla.
- ¿Cómo se asocia el mensaje al campo? Con
aria-describedbyapuntando aliddel mensaje, másaria-invalid="true"en el campo. Sin la asociación, el mensaje está en la pantalla pero el usuario de lector nunca lo encuentra: oye «no válido» y no sabe por qué. - ¿Cómo se entera el usuario al enviar? Con un resumen de errores al principio del formulario, dentro de una región activa, que enumere los campos con problema y enlace a cada uno. Después, foco al resumen o al primer campo inválido. Es la única forma de que un formulario de veinte campos con tres errores sea manejable.
<form [formGroup]="form" (ngSubmit)="enviar()">
<h3>Nueva tarea</h3>
<!-- placeholder como etiqueta: sin nombre accesible -->
<input formControlName="titulo" placeholder="Título"
class="campo" [class.error]="form.controls.titulo.invalid">
<!-- error NO asociado y con el color como único canal -->
<span class="rojo" *ngIf="form.controls.titulo.invalid">Obligatorio</span>
<!-- el asterisco no se anuncia; no hay required -->
<label>Proyecto *</label>
<select formControlName="proyectoId">…</select>
<!-- grupo de radios sin fieldset: "Alta, 1 de 3" ¿de qué? -->
<div class="radios">
<label><input type="radio" formControlName="prioridad" value="alta"> Alta</label>
<label><input type="radio" formControlName="prioridad" value="media"> Media</label>
</div>
<!-- div con clic: inalcanzable con teclado -->
<div class="btn-primario" (click)="enviar()">Crear</div>
</form>
<!-- Y al enviar con errores: nada se anuncia, el foco no se mueve
y el usuario no sabe qué campo ha fallado. -->
<form [formGroup]="form" (ngSubmit)="enviar()"
aria-labelledby="f-tit" novalidate>
<h2 id="f-tit">Nueva tarea</h2>
<!-- RESUMEN DE ERRORES: siempre en el DOM, con role="alert"
para que se anuncie al aparecer, y con enlaces al campo. -->
<div role="alert" #resumen tabindex="-1" class="resumen-errores">
@if (errores().length) {
<h3>No se ha podido crear la tarea</h3>
<p>Revisa {{ errores().length }} campos:</p>
<ul>
@for (e of errores(); track e.campo) {
<li><a [href]="'#' + e.campo">{{ e.etiqueta }}: {{ e.mensaje }}</a></li>
}
</ul>
}
</div>
<!-- Campo con etiqueta real, required nativo, ayuda y error -->
<div class="campo-grupo">
<label for="titulo">
Título <span class="req" aria-hidden="true">*</span>
</label>
<input id="titulo" type="text" formControlName="titulo" required
autocomplete="off" maxlength="120"
aria-describedby="titulo-ayuda titulo-error"
[attr.aria-invalid]="malo('titulo') ? 'true' : null">
<p id="titulo-ayuda" class="ayuda">Entre 3 y 120 caracteres.</p>
<p id="titulo-error" class="error">
@if (malo('titulo')) {
<svg class="i-alerta" aria-hidden="true" focusable="false"></svg>
{{ mensaje('titulo') }}
}
</p>
</div>
<!-- Grupo de opciones: fieldset + legend -->
<fieldset>
<legend>Prioridad</legend>
@for (p of prioridades; track p.valor) {
<div>
<input type="radio" [id]="'pri-' + p.valor" [value]="p.valor"
formControlName="prioridad">
<label [for]="'pri-' + p.valor">{{ p.etiqueta }}</label>
</div>
}
</fieldset>
<!-- Botón real, activo siempre; el estado se anuncia -->
<button type="submit" [attr.aria-disabled]="enviando() ? 'true' : null">
{{ enviando() ? 'Creando…' : 'Crear tarea' }}
</button>
</form>
Fíjate en dos detalles finos de la versión correcta. El asterisco de campo obligatorio lleva aria-hidden="true": el lector no dice «asterisco», dice «Título, edición, requerido», porque esa información ya la aporta el atributo required nativo. Y el párrafo del mensaje de error existe siempre en el DOM con su id, con el @if por dentro; así aria-describedby puede referenciarlo de forma estática y no hay que recalcular la lista de descripciones en cada cambio de validez.
const ETIQUETAS: Record<string, string> = {
titulo: 'Título', proyectoId: 'Proyecto', vence: 'Fecha de vencimiento',
};
const MENSAJES: Record<string, (e: unknown) => string> = {
required: () => 'Es obligatorio.',
minlength: (e) => `Necesita al menos ${(e as { requiredLength: number }).requiredLength} caracteres.`,
fechaPasada: () => 'No puede ser anterior a hoy. Formato dd/mm/aaaa.',
};
export class TareaFormComponent {
private readonly fb = inject(NonNullableFormBuilder);
private readonly resumen = viewChild.required<ElementRef<HTMLElement>>('resumen');
readonly enviando = signal(false);
readonly intentado = signal(false);
readonly form = this.fb.group({ /* … capítulo 5 … */ });
/** Mismo criterio de visualización que en el capítulo 5. */
malo(nombre: string): boolean {
const c = this.form.get(nombre)!;
return c.invalid && (c.touched || this.intentado());
}
mensaje(nombre: string): string {
const errores = this.form.get(nombre)?.errors ?? {};
const clave = Object.keys(errores)[0];
return clave ? MENSAJES[clave]?.(errores[clave]) ?? 'Valor no válido.' : '';
}
/** Alimenta el resumen. Es una señal derivada: no hay estado duplicado. */
readonly errores = computed(() => {
if (!this.intentado()) return [];
return Object.keys(ETIQUETAS)
.filter((campo) => this.form.get(campo)?.invalid)
.map((campo) => ({ campo, etiqueta: ETIQUETAS[campo], mensaje: this.mensaje(campo) }));
});
enviar(): void {
this.intentado.set(true);
if (this.form.invalid) {
this.form.markAllAsTouched();
// El foco va al RESUMEN, no al primer campo: así el usuario
// conoce el total de errores antes de empezar a corregir, y
// desde ahí navega con los enlaces. role="alert" además lo lee.
afterNextRender(() => this.resumen().nativeElement.focus(),
{ injector: this.injector });
return;
}
this.enviando.set(true);
// … envío, y mapeo de errores 400 de NestJS a los controles
// exactamente como en el capítulo 5, marcándolos como touched
// para que aparezcan en el resumen.
}
}Uno: <label for> en todos los campos. Dos: fieldset/legend en todo grupo de opciones. Tres: required nativo, no solo un asterisco. Cuatro: aria-describedby hacia la ayuda y el error, con los contenedores siempre presentes. Cinco: aria-invalid sincronizado con la validez visible. Seis: el mensaje explica cómo arreglarlo (3.3.3), no solo que hay un fallo. Siete: resumen de errores con role="alert" y foco al enviar. Ocho: autocomplete con los valores del estándar (1.3.5). Nueve: botón de envío nativo y siempre operable. Diez: confirmación de éxito anunciada en una región activa.
26.10 Color y contraste
El contraste es el criterio de WCAG que más fallos acumula en auditorías reales de productos de gestión como TaskFlow, y es también el más barato de prevenir si se fija en el sistema de tokens. Tres ratios importan en el día a día y conviene memorizarlos porque salen en cada revisión:
| Qué | Criterio WCAG | Ratio mínimo AA | Ejemplo en TaskFlow |
|---|---|---|---|
| Texto normal (< 18 pt / < 14 pt negrita) | 1.4.3 Contraste mínimo | 4,5:1 | Título de tarea, descripción, etiquetas de formulario, mensajes de ayuda |
| Texto grande (≥ 18 pt o ≥ 14 pt negrita) | 1.4.3 | 3:1 | Encabezados de sección, cifras grandes del tablero |
| UI no textual (bordes, iconos necesarios, indicador de foco) | 1.4.11 Contraste no textual | 3:1 frente a colores adyacentes | Borde del input, icono de estado, anillo de :focus-visible |
El ratio es la relación de luminancia relativa entre primer plano y fondo, definida en WCAG con una fórmula concreta. No lo calcules a ojo ni con una captura: usa una herramienta (DevTools de Chrome/Firefox, Contrast Checker de WebAIM, el panel Accessibility de Firefox) o calcula a partir de los tokens. El valor AAA (7:1 para texto normal) es deseable en entornos clínicos o de administración pública, pero el objetivo contractual habitual es AA.
26.10.1 El color no puede ser el único portador de información
El criterio 1.4.1 Uso del color (nivel A) es independiente del contraste: incluso con colores perfectos, si el estado de una tarea se comunica solo con un punto rojo/ámbar/verde, una persona con daltonismo —o cualquiera mirando la pantalla bajo el sol— no distingue los estados. La regla operativa es simple: todo lo que el color comunica debe tener un segundo canal (texto, icono con forma distinta, patrón, etiqueta).
<!-- Solo color: incumple 1.4.1 -->
<span class="punto" [style.background]="colorDe(tarea.estado)"></span>
<span>{{ tarea.titulo }}</span>
<span class="estado" [attr.data-estado]="tarea.estado">
<svg class="i-estado" aria-hidden="true" focusable="false"></svg>
<span class="sr-only">{{ etiquetaEstado(tarea.estado) }}:</span>
<span>{{ tarea.titulo }}</span>
</span>
<!-- CSS: forma + color + texto visible o sr-only -->
En gráficas del sprint, la leyenda no basta si las series solo se distinguen por tono: añade patrón (rayas, puntos) o etiquetas directas sobre las barras. En campos con error, el borde rojo debe ir acompañado de icono y texto; el color refuerza, no sustituye.
26.10.2 Tokens de diseño: el único sitio donde se decide el contraste
Si cada componente elige su gris «a ojo», el contraste se rompe en cada sprint. La paleta de TaskFlow se define una sola vez como tokens CSS (o tokens de diseño exportados a CSS), con pares primer plano / fondo verificados, y los componentes solo consumen esos tokens.
:root {
/* Texto sobre superficie: ratio ≥ 4,5:1 medido */
--tf-fg: #1a1f2c;
--tf-fg-muted: #4a5163; /* ≥ 4,5:1 sobre --tf-bg */
--tf-bg: #ffffff;
--tf-bg-subtle: #f4f6fa;
/* Acción primaria: texto blanco sobre marca ≥ 4,5:1 */
--tf-brand: #0b5fff;
--tf-brand-fg: #ffffff;
/* Borde de control: ≥ 3:1 frente a --tf-bg (1.4.11) */
--tf-border: #6b7280;
/* Foco: ≥ 3:1 frente a adyacentes */
--tf-focus: #0b5fff;
--tf-focus-offset: #ffffff;
/* Estados: color + se usará icono/texto en componentes */
--tf-ok: #0f7a3a;
--tf-warn: #9a5b00;
--tf-danger: #b42318;
}
@media (prefers-color-scheme: dark) {
:root {
--tf-fg: #eef1f6;
--tf-fg-muted: #b6bdd0;
--tf-bg: #0f1419;
--tf-bg-subtle: #1a222d;
--tf-border: #8b93a7;
--tf-brand: #6b9fff;
--tf-brand-fg: #0a1020;
}
}
El gris secundario suele bajarse hasta quedar a 2,8:1 «porque queda más elegante». Incumple 1.4.3. Si el texto transmite información (fecha de vencimiento, nombre del asignado, contador), debe cumplir 4,5:1. Reserva grises más claros solo para decoración puramente ornamental, que WCAG excluye del contraste de texto.
import { readFileSync } from 'node:fs';
/** Luminancia relativa según WCAG 2. */
function lum(hex: string): number {
const n = hex.replace('#', '');
const rgb = [0, 2, 4].map((i) => {
const c = parseInt(n.slice(i, i + 2), 16) / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2];
}
function ratio(a: string, b: string): number {
const [L1, L2] = [lum(a), lum(b)].sort((x, y) => y - x);
return (L1 + 0.05) / (L2 + 0.05);
}
const pares: [string, string, number, string][] = [
['#1a1f2c', '#ffffff', 4.5, 'fg / bg'],
['#4a5163', '#ffffff', 4.5, 'fg-muted / bg'],
['#ffffff', '#0b5fff', 4.5, 'brand-fg / brand'],
['#6b7280', '#ffffff', 3.0, 'border / bg (UI)'],
];
for (const [fg, bg, min, nombre] of pares) {
const r = ratio(fg, bg);
if (r < min) {
console.error(`FALLO ${nombre}: ${r.toFixed(2)} < ${min}`);
process.exitCode = 1;
} else {
console.log(`OK ${nombre}: ${r.toFixed(2)}`);
}
}
Enlaza este script al pipeline junto a las pruebas de axe. No sustituye una auditoría visual, pero impide que un cambio de marca baje el contraste sin que nadie se entere.
26.10.3 Temas del sistema y contraste forzado
Además del modo claro/oscuro, Windows ofrece temas de contraste alto. Con forced-colors: active el navegador sustituye gran parte de tu paleta por colores del sistema. Los bordes hechos solo con box-shadow o fondos semitransparentes pueden desaparecer. La defensa mínima:
@media (forced-colors: active) {
.tf-btn {
border: 1px solid ButtonText;
forced-color-adjust: none; /* solo si redefines con system colors */
}
.tf-btn:focus-visible {
outline: 2px solid Highlight;
outline-offset: 2px;
}
.estado[data-estado='bloqueada'] {
/* No confíes en el color: el icono y el texto ya van en el HTML */
}
}
26.11 Contenido dinámico
En una SPA como TaskFlow casi todo el contenido llega después de la primera pintura: listas que se cargan, toasts de «tarea guardada», resultados de filtrado, contadores del tablero. El usuario visual lo percibe porque ve el movimiento; el usuario de lector de pantalla solo se entera si tú anuncias el cambio. La herramienta es la región activa (aria-live / role="status" / role="alert"), ya introducida en 26.6.4; aquí la aplicamos a los patrones concretos del producto.
26.11.1 Estados de carga
Un spinner sin nombre es decoración muda. Tres piezas: (1) un indicador visible con texto o nombre accesible, (2) aria-busy="true" en el contenedor que se está actualizando, (3) un anuncio al terminar («12 tareas cargadas») en una región polite.
<section aria-labelledby="titulo-lista"
[attr.aria-busy]="cargando() ? 'true' : null">
<h2 id="titulo-lista">Tareas del proyecto</h2>
@if (cargando()) {
<p class="carga" role="status">
<span class="spinner" aria-hidden="true"></span>
Cargando tareas…
</p>
} @else {
<ul>…</ul>
}
</section>
<!-- Contenedor SIEMPRE en el DOM (regla de 26.6.4) -->
<div class="sr-only" aria-live="polite" aria-atomic="true">
@if (!cargando() && anuncio()) { {{ anuncio() }} }
</div>
readonly cargando = signal(true);
readonly anuncio = signal('');
async ngOnInit(): Promise<void> {
this.cargando.set(true);
const tareas = await this.api.listar();
this.tareas.set(tareas);
this.cargando.set(false);
this.anuncio.set(`${tareas.length} tareas cargadas`);
}
26.11.2 Notificaciones y toasts
Un toast de éxito es role="status" (equivalente a aria-live="polite"). Un aviso de «la sesión caduca en un minuto» es role="alert". Nunca uses alert para confirmaciones rutinarias: interrumpir al lector cada vez que se guarda una tarea lo vuelve inutilizable. Si usas el CDK, LiveAnnouncer encapsula exactamente este contrato (sección 26.13).
@Injectable({ providedIn: 'root' })
export class ToastService {
private readonly live = inject(LiveAnnouncer);
readonly actual = signal<{ texto: string; tono: 'ok' | 'error' } | null>(null);
async exito(texto: string): Promise<void> {
this.actual.set({ texto, tono: 'ok' });
await this.live.announce(texto, 'polite');
}
async error(texto: string): Promise<void> {
this.actual.set({ texto, tono: 'error' });
await this.live.announce(texto, 'assertive');
}
}
26.11.3 prefers-reduced-motion
El criterio 2.3.3 (animación desde interacciones, AAA) y la buena práctica general exigen respetar la preferencia del sistema. En TaskFlow: sin parallax, sin carruseles automáticos, transiciones cortas o nulas, y ningún movimiento esencial para entender el estado (el estado debe existir también en estático).
:root {
--tf-dur: 180ms;
--tf-ease: cubic-bezier(0.2, 0.8, 0.2, 1);
}
@media (prefers-reduced-motion: reduce) {
:root { --tf-dur: 0.01ms; }
.tf-slide, .tf-fade, .skeleton-shine {
animation: none !important;
transition: none !important;
}
}
/** Útil para desactivar animaciones de Angular Animations. */
export function prefiereMenosMovimiento(): boolean {
return typeof matchMedia === 'function'
&& matchMedia('(prefers-reduced-motion: reduce)').matches;
}
Al filtrar la lista de tareas no muevas el foco (el usuario sigue en el campo de búsqueda). Anuncia en polite el número de resultados: «8 tareas coinciden». Si el resultado es cero, dilo explícitamente: el silencio se interpreta como «aún cargando».
26.12 Imágenes, iconos y multimedia
El criterio 1.1.1 (contenido no textual) es el más antiguo de WCAG y sigue siendo de los más incumplidos. La regla se resume en una pregunta: si quito la imagen, ¿se pierde información? Si sí, necesita alternativa textual. Si no (es decorativa), debe ocultarse del árbol de accesibilidad.
26.12.1 alt útil frente a alt vacío
| Tipo | Marcado | Ejemplo TaskFlow |
|---|---|---|
| Informativa | alt="descripción concisa" | Captura del tablero en la guía de ayuda: alt="Tablero Kanban con columnas Por hacer, En curso y Hecho" |
| Decorativa | alt="" (vacío, no omitido) o aria-hidden="true" | Ilustración de fondo del vacío de lista; icono redundante junto a texto visible |
| Funcional (enlace/botón solo icono) | El nombre lo aporta el control, no la imagen | Botón papelera: aria-label="Eliminar tarea Diseño de API"; el svg va con aria-hidden="true" |
| Compleja (gráfico, diagrama) | alt corto + descripción larga (aria-describedby o enlace a alternativa) | Gráfico de burndown del sprint |
<!-- alt omitido: el lector puede leer la URL del fichero -->
<img src="/avatars/{{ usuario.id }}.png">
<!-- alt inútil -->
<img src="chart.png" alt="imagen">
<!-- icono clicable sin nombre -->
<button type="button" (click)="borrar()">
<img src="trash.svg" alt="">
</button>
<img [src]="usuario.avatarUrl"
[alt]="'Avatar de ' + usuario.nombre">
<img src="chart.png"
alt="Burndown del sprint 14"
aria-describedby="desc-burndown">
<p id="desc-burndown">Quedan 32 puntos; la tendencia…</p>
<button type="button"
[attr.aria-label]="'Eliminar tarea ' + tarea.titulo"
(click)="borrar()">
<svg aria-hidden="true" focusable="false"></svg>
</button>
26.12.2 Iconos con nombre accesible
Un icono SVG inline es, por defecto, un gráfico sin rol útil. Patrones correctos:
- Icono decorativo junto a texto visible:
aria-hidden="true"yfocusable="false"en el SVG. El nombre lo aporta el texto. - Botón o enlace solo icono: nombre en el control (
aria-labelo texto.sr-only), SVG oculto. - Icono informativo sin texto:
role="img"+aria-labelen el SVG, o<title>dentro del SVG referenciado conaria-labelledby.
<!-- Informativo, sin texto hermano -->
<svg role="img" aria-label="Prioridad alta" width="16" height="16">
<use href="sprite.svg#prioridad-alta"></use>
</svg>
<!-- Decorativo junto a etiqueta visible -->
<span class="badge">
<svg aria-hidden="true" focusable="false"></svg>
Prioridad alta
</span>
26.12.3 Vídeo y audio de ayuda
Si TaskFlow incluye un vídeo de onboarding o un podcast interno: subtítulos sincronizados (1.2.2), descripción de audio o transcripción cuando haya información visual no hablada (1.2.3/1.2.5), y control de reproducción operable por teclado. No reproduzcas automáticamente con sonido (1.4.2). Un PDF o una página HTML con la misma información suele ser la alternativa más robusta para formación interna.
26.13 Angular: CDK a11y, Material y el linter de plantillas
Angular no te hace accesible por magia, pero el paquete @angular/cdk/a11y concentra utilidades que resolverías mal si las reimplementas. Angular Material las usa por debajo. El linter de plantillas atrapa otra familia de errores antes de llegar al navegador.
26.13.1 FocusTrap y CdkTrapFocus
Un diálogo modal debe mantener el Tab dentro de sí (2.1.2, 2.4.3). El CDK ofrece una directiva declarativa y un servicio imperativo.
import { A11yModule, CdkTrapFocus } from '@angular/cdk/a11y';
@Component({
selector: 'tf-confirmar-borrado',
imports: [A11yModule],
template: `
<div class="dialogo" role="dialog" aria-modal="true"
aria-labelledby="dlg-titulo" cdkTrapFocus cdkTrapFocusAutoCapture>
<h2 id="dlg-titulo">¿Eliminar la tarea?</h2>
<p>Esta acción no se puede deshacer.</p>
<button type="button" (click)="cancelar()">Cancelar</button>
<button type="button" class="peligro" (click)="confirmar()">
Eliminar
</button>
</div>
`,
})
export class ConfirmarBorradoComponent {
/* Al destruir el diálogo, el servicio que lo abrió
restaura el foco al disparador (patrón de 26.8). */
}
cdkTrapFocusAutoCapture mueve el foco al primer tabulable al crear la trampa. Sin restaurar el foco al cerrar, el usuario de teclado «salta» al inicio del documento: es el fallo más reportado en SPAs con modales caseros.
26.13.2 LiveAnnouncer
En lugar de gestionar a mano un nodo aria-live (con la trampa del contenedor que debe existir antes), el CDK mantiene una región oculta y expone announce(texto, 'polite' | 'assertive').
import { LiveAnnouncer } from '@angular/cdk/a11y';
export class TableroComponent {
private readonly live = inject(LiveAnnouncer);
async moverTarea(t: Tarea, columna: string): Promise<void> {
await this.api.mover(t.id, columna);
await this.live.announce(
`Tarea ${t.titulo} movida a ${columna}`,
'polite',
);
}
}
26.13.3 FocusMonitor
Distingue el origen del foco (teclado, ratón, táctil, programa). Sirve para aplicar estilos de foco solo cuando importan —complemento de :focus-visible— y para telemetría interna de componentes de diseño.
import { FocusMonitor } from '@angular/cdk/a11y';
export class TfButtonComponent implements OnInit, OnDestroy {
private readonly fm = inject(FocusMonitor);
private readonly host = inject(ElementRef<HTMLElement>);
readonly origen = signal<string | null>(null);
ngOnInit(): void {
this.fm.monitor(this.host, true).subscribe((origen) => {
this.origen.set(origen);
// origen === 'keyboard' → anillo visible forzado por clase
});
}
ngOnDestroy(): void {
this.fm.stopMonitoring(this.host);
}
}
26.13.4 Material y el linter de plantillas
Angular Material construye diálogos, menús, selectores y snackbars sobre el CDK: atrapan foco, gestionan Escape, anuncian aperturas. Si tu diseño lo permite, extiende Material en lugar de reinventar el modal. Lo que Material no resuelve solo: tu semántica de dominio (nombres de botones de icono, textos de error, estructura de la página, contraste de la tema personalizada).
El compilador de plantillas de Angular, con las opciones de accesibilidad del esquema, avisa de img sin alt, elementos clicables sin teclado, contrastes básicos y atributos ARIA inválidos. Actívalo en el proyecto:
{
"projects": {
"taskflow": {
"architect": {
"build": {
"options": {
"optimization": true
}
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": ["src/**/*.ts", "src/**/*.html"]
}
}
}
}
}
}
// Extiende plugin:@angular-eslint/template/accessibility
export default [
{
files: ['**/*.html'],
extends: [
'plugin:@angular-eslint/template/recommended',
'plugin:@angular-eslint/template/accessibility',
],
rules: {
'@angular-eslint/template/click-events-have-key-events': 'error',
'@angular-eslint/template/interactive-supports-focus': 'error',
'@angular-eslint/template/label-has-associated-control': 'error',
'@angular-eslint/template/alt-text': 'error',
'@angular-eslint/template/valid-aria': 'error',
},
},
];
Linter de plantillas atrapa lo estático. CDK atrapa el comportamiento (foco, anuncios). axe en pruebas atrapa el DOM renderizado. Teclado + lector atrapan lo que ninguna regla automática ve. Ninguna capa sustituye a las otras.
26.14 Cómo se prueba la accesibilidad
La accesibilidad no se «siente» en una demo con el ratón. Se verifica con tres capas que se refuerzan: automatización (barata, incompleta), teclado (obligatoria, rápida) y lector de pantalla (cara en tiempo, insustituible en componentes nuevos). Una regla empírica fiable: las herramientas automáticas detectan en torno al 30–40 % de los problemas reales. El resto es interacción y semántica que solo un humano encuentra.
26.14.1 axe y Lighthouse
axe-core (Deque) es el motor de reglas más usado en la industria. Lo ejecutas en el navegador (extensión axe DevTools), en Lighthouse (auditoría Accessibility) o embebido en pruebas. Lighthouse Accessibility es, en gran parte, axe con una puntuación agregada: útil como señal, peligroso como único KPI («hemos llegado a 100» no significa conforme a WCAG).
# En Chrome: instalar axe DevTools
# Abrir TaskFlow → panel axe → Scan ALL of my page
# Priorizar violaciones "serious" y "critical"
# Exportar JSON y abrir incidencias enlazadas al criterio WCAG
26.14.2 Protocolo de teclado (cinco minutos)
Antes de cada merge de UI, alguien del equipo (no solo el autor) recorre esto sin tocar el ratón:
- Tab desde la barra de dirección hasta el final de la vista: ¿se ve el foco en todo momento? ¿El orden es lógico?
- ¿Se puede llegar a todos los controles y activarlos con Enter/Espacio?
- Abrir el diálogo de confirmación: ¿Tab cicla dentro? ¿Escape cierra? ¿El foco vuelve al disparador?
- Menú de usuario y selector de proyecto: ¿flechas, Home/End, Escape según el patrón APG?
- Formulario de nueva tarea: etiquetas, errores anunciados, envío y foco al resumen si falla.
Si algo falla aquí, no hace falta VoiceOver todavía: ya tienes un defecto bloqueante.
26.14.3 Lector de pantalla
Mínimo viable por plataforma: NVDA + Firefox o Chrome en Windows (gratuito), VoiceOver + Safari en macOS/iOS, TalkBack en Android para la vista móvil. No intentes dominar los tres el mismo día: elige el de tus usuarios mayoritarios y documenta un guion de cinco pantallas críticas (login, lista, detalle, formulario, diálogo). Escucha el nombre, el rol y el estado; si oyes «grupo» o «clicable» genérico donde esperabas «botón» o «casilla», el árbol está mal.
26.14.4 jest-axe / vitest-axe en componentes
import { TestBed } from '@angular/core/testing';
import { axe, toHaveNoViolations } from 'jest-axe';
import { TareaFormComponent } from './tarea-form.component';
expect.extend(toHaveNoViolations);
describe('TareaFormComponent a11y', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [TareaFormComponent],
});
});
it('no tiene violaciones axe en estado inicial', async () => {
const fixture = TestBed.createComponent(TareaFormComponent);
fixture.detectChanges();
const resultados = await axe(fixture.nativeElement);
expect(resultados).toHaveNoViolations();
});
it('no tiene violaciones con errores visibles', async () => {
const fixture = TestBed.createComponent(TareaFormComponent);
const cmp = fixture.componentInstance;
cmp.intentado.set(true);
fixture.detectChanges();
const resultados = await axe(fixture.nativeElement);
expect(resultados).toHaveNoViolations();
});
});
Configura axe con las reglas WCAG 2.1 AA (o 2.2 AA si es tu compromiso). Desactiva reglas solo con justificación documentada en el propio test; un rules: { 'color-contrast': { enabled: false } } global es una deuda disfrazada de verde.
26.14.5 Puerta en integración continua
name: a11y
on: [pull_request]
jobs:
lint-y-axe:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npx ng lint
- run: npm test -- --coverage=false
- run: npx ng build --configuration=production
- run: npx playwright test e2e/a11y.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('Auditoría axe de rutas críticas', () => {
for (const ruta of ['/login', '/proyectos', '/proyectos/1/tareas']) {
test(`${ruta} sin violaciones graves`, async ({ page }) => {
await page.goto(ruta);
const resultados = await new AxeBuilder({ page })
.withTags(['wcag21aa', 'wcag22aa'])
.analyze();
const graves = resultados.violations.filter(
(v) => v.impact === 'critical' || v.impact === 'serious',
);
expect(graves).toEqual([]);
});
}
});
Puedes tener 100 y un diálogo modal que no atrapa el foco, un carrusel solo con ratón o errores de formulario no asociados. Usa Lighthouse como humo diario; usa teclado + lector + criterios WCAG como aceptación.
26.15 Documentos del servidor: correos y PDF
La accesibilidad no termina en el navegador. NestJS genera correos transaccionales («te han asignado una tarea»), PDF de informes de sprint y, a veces, HTML de facturación. Esos artefactos también están sujetos a EN 301 549 / WCAG cuando el cliente es administración pública o el contrato lo exige, y en cualquier caso son la única interfaz de muchos usuarios en ese momento.
26.15.1 Correos HTML
- HTML semántico simple: encabezados reales, listas, enlaces con texto descriptivo («Abrir tarea Diseño de API»), no «pincha aquí».
- Texto en HTML, no en imagen. Si el logotipo es imagen,
alt="TaskFlow". - Contraste de texto y botones igual que en la web (4,5:1 / 3:1).
- Versión en texto plano multipart alternativa: muchos lectores y clientes corporativos la prefieren.
- No dependas del color del semáforo de prioridad: incluye la palabra «Alta» / «Media» / «Baja».
<!-- Plantilla de correo: marcado lineal y predecible -->
<h1>Nueva tarea asignada</h1>
<p>Hola {{nombre}},</p>
<p>{{asignador}} te ha asignado la tarea
<strong>{{titulo}}</strong>
(prioridad {{prioridadEtiqueta}})
en el proyecto {{proyecto}}.</p>
<p><a href="{{url}}">Abrir la tarea {{titulo}} en TaskFlow</a></p>
<p>Si el botón no funciona, copia esta URL:<br>{{url}}</p>
async enviarAsignacion(datos: AsignacionCorreo): Promise<void> {
const html = this.motor.render('correo-asignacion', datos);
const text = [
`Nueva tarea asignada`,
`Hola ${datos.nombre},`,
`${datos.asignador} te ha asignado «${datos.titulo}»`,
`(prioridad ${datos.prioridadEtiqueta}) en ${datos.proyecto}.`,
`Abrir: ${datos.url}`,
].join('\n\n');
await this.mailer.sendMail({
to: datos.email,
subject: `TaskFlow: te han asignado «${datos.titulo}»`,
html,
text, // alternativa accesible y robusta
});
}
26.15.2 PDF generados
Un PDF de «solo imagen» (impresión a mapa de bits o canvas sin etiquetas) es un muro para lectores de pantalla. Si generas PDF en el servidor:
- Usa un motor que produzca PDF etiquetado (tagged PDF): estructura de encabezados, párrafos, listas y tablas reales (p. ej. rutas con HTML→PDF que preserven semántica, o bibliotecas que escriban roles de etiqueta).
- Orden de lectura coherente con el orden visual.
- Texto seleccionable, no escaneado.
- Idioma del documento declarado.
- Cuando el PDF sea un anexo de cumplimiento, ofrece también HTML o CSV con los mismos datos.
/**
* Preferimos generar HTML semántico y convertirlo con un motor
* que preserve etiquetas. Si el cliente solo pide Excel/CSV para
* datos tabulares, es más accesible y más barato que un PDF bonito.
*/
async generarInforme(sprintId: string): Promise<Buffer> {
const datos = await this.repo.resumen(sprintId);
const html = this.vista.render('informe-sprint', {
...datos,
lang: 'es',
titulo: `Informe del sprint ${datos.nombre}`,
});
return this.pdf.desdeHtmlEtiquetado(html, { lang: 'es' });
}
Un correo o un PDF inaccesible es como una API que solo habla un codec propietario: el recurso existe, pero el cliente legítimo no puede consumirlo. La alternativa textual (multipart, HTML, CSV) es el Accept de la accesibilidad en el servidor.
26.16 Errores comunes y cómo solucionarlos
| Síntoma | Causa real | Solución |
|---|---|---|
| El lector dice «clicable» o «grupo» en un botón | div/span con (click) sin rol ni teclado | Usar <button type="button"> nativo |
aria-live «no anuncia nada» | El contenedor se crea en el mismo tick que el texto | Región siempre en el DOM; cambia solo el contenido interior (o usa LiveAnnouncer) |
| Tras cerrar un modal el foco salta al logo | No se restauró el foco al disparador | Guardar el elemento activo al abrir y devolverle el foco al destruir |
| Tab sale del diálogo | Trampa de foco ausente o rota | cdkTrapFocus / FocusTrap del CDK; aria-modal="true" |
| Campo «sin etiqueta» en axe | placeholder como única etiqueta | <label for> visible o asociado; placeholder solo como ejemplo |
| Errores de formulario invisibles al lector | Mensaje no enlazado con aria-describedby | IDs estables + aria-invalid + resumen con role="alert" |
| Contraste «bien en Figma, mal en producción» | Tokens distintos o texto muted rebajado a mano | Única fuente de tokens + script de ratio en CI |
| Estados de tarea indistinguibles | Solo color (1.4.1) | Icono con forma distinta + texto o sr-only |
| Botón icono mudo | SVG sin nombre y sin aria-label en el botón | Nombre en el control; SVG con aria-hidden="true" |
| Al borrar una fila el foco se pierde | Se eliminó el nodo enfocado | Reubicar el foco a la fila siguiente/anterior o al vacío de lista (26.8) |
| Lighthouse 100 pero usuarios se quejan | Confiar solo en reglas automáticas | Protocolo de teclado + guion con NVDA/VoiceOver |
| Animaciones marean | Ignorar prefers-reduced-motion | Tokens de duración a casi cero; quitar animaciones decorativas |
| Imagen leída como URL larga | Falta el atributo alt | alt descriptivo o alt="" si es decorativa |
| Menú con ratón ok, teclado imposible | Solo :hover, sin patrón APG | Botón + flechas + Escape; o componente Material/CDK |
| Correo «bonito» ilegible con lector | Layout en tablas anidadas + texto en imagen | HTML lineal + texto plano multipart |
| PDF del informe no se puede leer | PDF no etiquetado / solo imagen | Motor tagged PDF o alternativa HTML/CSV |
26.17 Buenas y malas prácticas
Buenas prácticas
- Empieza por HTML semántico; ARIA solo cuando el nativo no alcanza.
- Define contraste y color en tokens verificados (4,5:1 texto, 3:1 UI).
- Segundo canal siempre: nunca solo color para estado o error.
- Gestiona el foco en cada ruta, modal y borrado de lista.
- Regiones activas presentes en el DOM antes del mensaje; prioriza
polite. - Usa
FocusTrap,LiveAnnounceryFocusMonitordel CDK. - Prefiere Angular Material/CDK a reimplementar diálogos y menús.
- Activa el linter
template/accessibilitycomo error de CI. - Añade jest-axe/vitest-axe en componentes críticos y axe en Playwright.
- Protocolo de teclado de cinco minutos en cada cambio de UI.
- Respeta
prefers-reduced-motionyforced-colors. altútil o vacío consciente; iconos con nombre en el control.- Etiquetas,
fieldset, errores asociados y resumen al enviar. - Correos con texto plano; PDF etiquetados o alternativa tabular.
- Documenta excepciones de reglas axe con dueño y fecha de revisión.
Malas prácticas
- Poner
aria-labelen todo «por si acaso» y tapar el nombre visible. - Usar
role="button"en undiven lugar de<button>. - Eliminar
outlinesin sustituir por:focus-visiblecontrastado. - Abusar de
aria-live="assertive"en toasts rutinarios. - Validar en rojo mientras el usuario escribe la primera vez.
- Confiar en el
placeholdercomo etiqueta. - Tomar Lighthouse 100 como definición de hecho.
- Desactivar contraste en axe porque «el diseño lo manda».
- Reinventar un modal sin trampa de foco ni restauración.
- Animaciones esenciales sin alternativa estática.
- Iconos SVG clicables sin nombre accesible.
- Información solo en color (prioridad, error, series de gráfica).
- Omitir
alto poneralt="imagen". - Probar accesibilidad solo con el ratón en el monitor del diseñador.
- Generar PDF escaneados o correos enteros como imagen.
- Aplazar la a11y a «la semana de pulido» anterior a producción.
26.18 Preguntas frecuentes
¿WCAG A, AA o AAA? ¿Cuál debo prometer en un contrato?
¿Puedo cumplir WCAG solo con la extensión axe?
¿Angular Material me hace automáticamente accesible?
¿Cuándo está justificado usar ARIA?
button, a, input, dialog en navegadores modernos, details), úsalo. La primera regla de ARIA existe porque un rol incorrecto es peor que ninguno: engañas al usuario de lector con un contrato que tu teclado no cumple.¿Por qué mi región activa anuncia dos veces o ninguna?
aria-live se insertó junto con el texto. Dos veces: tienes a la vez un role="alert" visual y un LiveAnnouncer que repite el mismo mensaje, o un toast Material más tu propio anuncio. Unifica el canal: o el DOM visible es la región activa, o anuncias por CDK y el toast es puramente visual sin roles de alerta duplicados. Y si el mismo string se reasigna sin cambiar, muchos lectores no repiten: fuerza un cambio (espacio, contador, borrado previo).¿El modo oscuro exige otros ratios?
¿Debo soportar lectores de pantalla en pruebas unitarias?
¿Qué hago con un calendario o un editor enriquecido?
¿Los tests de axe en CI sustituyen la auditoría del cliente?
¿Cómo nombro un botón de icono que actúa sobre una fila?
[attr.aria-label]="'Eliminar tarea ' + tarea.titulo" o texto .sr-only equivalente. El título visible de la fila no sustituye ese nombre si el botón está lejos en el orden de lectura.¿Puedo ocultar el indicador de foco para «usuarios de ratón»?
:focus-visible (o FocusMonitor) para mostrar el anillo cuando la navegación es por teclado y atenuarlo en clic de ratón. Lo que no puedes hacer es :focus { outline: none } sin alternativa: incumple 2.4.7 y deja la app inutilizable con teclado. El anillo debe cumplir contraste no textual 3:1.¿La accesibilidad perjudica el rendimiento?
aria-live, un label y un token de contraste no añaden coste relevante. Lo que sí puede costar es un árbol DOM enorme anunciado en cada tecla de un filtro: ahí el remedio es debouncing del anuncio y aria-atomic bien elegido, no «quitar accesibilidad». Si un widget accesible es lento, suele ser que el widget está mal diseñado para todos, no solo para el lector.¿Qué prioridad doy si el backlog está lleno?
¿Cómo demuestro conformidad a un pliego público?
26.19 Ejercicios
- Audita con axe DevTools la vista de lista de tareas de TaskFlow. Clasifica cada violación por criterio WCAG y estima el esfuerzo de arreglo en horas.
- Recorre solo con teclado el flujo «crear tarea → error de validación → corregir → éxito». Anota dónde desaparece el foco o falta el anillo.
- Mide el contraste de
--tf-fg-mutedsobre--tf-bgen claro y oscuro. Corrige los tokens que no lleguen a 4,5:1 y añade el script de verificación al CI. - Sustituye tres botones de icono mudos por equivalentes con
aria-labelcontextual (incluye el título de la tarea).
- Implementa el formulario de tarea según 26.9:
label,fieldsetde prioridad,aria-describedby, resumen de errores con foco y anuncio de éxito víaLiveAnnouncer. - Añade una región activa para el filtrado de la lista que anuncie «N tareas coinciden» o «Ninguna tarea coincide» sin mover el foco del campo de búsqueda.
- Envuelve el diálogo de confirmación de borrado con
cdkTrapFocusy restaura el foco al botón que lo abrió al cerrar. - Escribe dos pruebas jest-axe (estado limpio y con errores) y una prueba Playwright con
@axe-core/playwrightpara/proyectos/:id/tareas. - Rediseña el indicador de estado de tarea para cumplir 1.4.1 (color + icono + texto) y verifica 1.4.11 en el icono.
- Implementa el protocolo completo de gestión de foco al eliminar una tarjeta del tablero Kanban (siguiente, anterior o vacío), con pruebas de unidad que espíen
focus(). - Genera en NestJS el correo de asignación con HTML semántico + texto plano, y un PDF de informe de sprint etiquetado (o justifica y entrega CSV+HTML equivalentes).
- Configura ESLint
template/accessibilitycomoerror, corrige el proyecto hasta dejar CI en verde y documenta las únicas reglas desactivadas con motivo. - Monta una página de «declaración de accesibilidad» de TaskFlow (HTML) enlazada desde el pie, con nivel WCAG, fecha, limitaciones conocidas y contacto.
- Realiza un guion de treinta minutos con NVDA o VoiceOver sobre cinco pantallas y abre issues con evidencia (qué se oyó vs qué se esperaba).
Solución comentada del ejercicio 6: anuncio de filtrado
import { LiveAnnouncer } from '@angular/cdk/a11y';
import { debounceTime, distinctUntilChanged } from 'rxjs';
export class FiltroTareasComponent {
private readonly live = inject(LiveAnnouncer);
readonly query = signal('');
readonly visibles = computed(() => this.filtrar(this.query()));
constructor() {
// Debounce: no anunciar en cada tecla.
toObservable(this.query).pipe(
debounceTime(300),
distinctUntilChanged(),
).subscribe(() => {
const n = this.visibles().length;
const msg = n === 0
? 'Ninguna tarea coincide con el filtro'
: `${n} tareas coinciden`;
void this.live.announce(msg, 'polite');
});
}
}
No se mueve el foco: el usuario sigue en el input. El anuncio va en polite para no cortar la lectura del carácter. El debounceTime(300) evita un torrente de anuncios. Si prefieres DOM en lugar de CDK, un div.sr-only con aria-live="polite" siempre montado y [textContent] actualizado produce el mismo efecto.
Solución comentada del ejercicio 7: trampa de foco en el diálogo
@Injectable({ providedIn: 'root' })
export class DialogoBorradoService {
private disparador: HTMLElement | null = null;
abrir(disparador: HTMLElement, tarea: Tarea): void {
this.disparador = disparador;
// Abre el overlay/dialog; el template usa cdkTrapFocusAutoCapture
this.ref.set({ tarea });
}
cerrar(): void {
this.ref.set(null);
// Devolver el foco en el siguiente frame, cuando el diálogo ya no está
queueMicrotask(() => this.disparador?.focus());
this.disparador = null;
}
}
<div role="dialog" aria-modal="true" aria-labelledby="t"
cdkTrapFocus cdkTrapFocusAutoCapture>
<h2 id="t">¿Eliminar «{{ tarea.titulo }}»?</h2>
<button type="button" (click)="svc.cerrar()">Cancelar</button>
<button type="button" (click)="confirmar()">Eliminar</button>
</div>
Guardar el disparador antes de abrir es imprescindible: al destruir el diálogo, ese botón sigue existiendo en la lista. Escape debe llamar a cerrar() (Material Dialog lo hace solo; un modal casero debe escuchar keydown.escape).
Solución comentada del ejercicio 8: jest-axe en el formulario
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
it('formulario inválido anunciado sigue sin violaciones', async () => {
const fixture = TestBed.createComponent(TareaFormComponent);
const cmp = fixture.componentInstance;
cmp.intentado.set(true);
cmp.form.markAllAsTouched();
fixture.detectChanges();
// El resumen de errores debe estar en el DOM con role="alert"
const alert = fixture.nativeElement.querySelector('[role="alert"]');
expect(alert).toBeTruthy();
const resultados = await axe(fixture.nativeElement, {
runOnly: { type: 'tag', values: ['wcag21aa'] },
});
expect(resultados).toHaveNoViolations();
});
La aserción explícita sobre [role="alert"] cubre lo que axe a veces no exige: que el patrón de resumen exista. Combina reglas automáticas con expectativas de dominio. Si una violación de contraste aparece por un token de historia de Storybook distinto al de la app, arregla el harness, no silencies la regla.
26.20 Resumen del capítulo
- La accesibilidad es un contrato verificable (WCAG / EN 301 549), no un barniz de
aria-label. - El navegador expone un árbol de accesibilidad; el lector solo lee ese árbol. HTML semántico construye el 70 % del trabajo.
- La primera regla de ARIA: no uses ARIA si el nativo basta. Un rol sin teclado es una mentira.
- Teclado y foco en SPA (rutas, modales, borrados) son el denominador común de casi todas las discapacidades.
- Formularios: etiqueta real, agrupación, errores asociados, resumen anunciado, botón nativo.
- Contraste AA: 4,5:1 texto normal, 3:1 texto grande y UI; el color nunca es el único canal.
- Contenido dinámico: regiones activas bien montadas,
prefers-reduced-motion, anunciospolitepor defecto. - Imágenes e iconos:
altútil o vacío; nombre en el control para iconos funcionales. - CDK (
FocusTrap,LiveAnnouncer,FocusMonitor) + Material + linter de plantillas forman la base Angular. - Pruebas en capas: lint → axe en unit/e2e → teclado → lector. Lighthouse 100 no es conformidad.
- Correos y PDF del servidor también cuentan: texto plano, PDF etiquetado o alternativa HTML/CSV.
26.21 Recursos adicionales
- WCAG 2.2 Quick Reference — criterios filtrables por nivel A/AA/AAA.
- ARIA Authoring Practices Guide (APG) — patrones de teclado y roles para widgets.
- MDN · Accesibilidad — guías y referencia de ARIA y HTML semántico.
- axe / axe-core — motor de reglas y extensiones de auditoría.
- angular.dev · Accessibility — prácticas oficiales y enlace al CDK a11y.
- Angular CDK · a11y —
FocusTrap,LiveAnnouncer,FocusMonitor. - W3C WAI · WCAG Overview — contexto de las versiones 2.0/2.1/2.2.
- WebAIM Contrast Checker — cálculo de ratios sobre pares de color.
- Documentación WAI relacionada y búsqueda de EN 301 549 en el organismo de normalización de tu país.
- jest-axe — integración de axe en Jest (análogo en Vitest disponible).
- NVDA — lector de pantalla gratuito para Windows.
- WAI Tutorials — formularios, imágenes, tablas y menús explicados con ejemplos.
Este capítulo se apoya en el 3 (plantillas y DOM), el 5 (formularios reactivos) y el 24 (sistema de UI). La gestión de foco en el enrutado conecta con el capítulo de routing; las pruebas en CI, con el de testing y DevOps. El capítulo 27 (PWA) añade restricciones nuevas: UI offline y notificaciones push también deben anunciarse y operarse con teclado. El 29 (i18n) recuerda que el nombre accesible y los anuncios viven en las mismas cadenas traducidas que el resto de la interfaz.