accesibilidad en angular: foco, componentes y lo que el linter no te diceaccessibility in angular: focus, components and what the linter won't tell you
This article is written in Spanish.
🚧 En construcción. Este artículo es la primera versión de una colaboración con A11Y PRO (abre en una nueva ventana) y se irá ampliando junto con su post complementario.
juferoga@portfolio:~/blog$ ng lint --fix && echo "¿y ahora sí es accesible?"
¿y ahora sí es accesible?
# spoiler: no necesariamente
La accesibilidad suele llegar tarde a los proyectos: cuando alguien corre una auditoría a dos semanas de salir a producción, o cuando una entidad pública recuerda que tiene que cumplir la norma. En Angular eso duele más de lo necesario, porque el framework trae casi todo lo que hace falta para hacerlo bien desde el día uno; solo hay que saber dónde está.
Este post es la mitad técnica de una colaboración con A11Y PRO (abre en una nueva ventana): aquí va el cómo se construye en Angular; allá, el cómo se audita y valida contra WCAG 2.2.
El problema que Angular te crea (sin querer)
En una aplicación de una sola página (SPA), cambiar de ruta no recarga el documento. Para quien navega con mouse da igual; para quien usa lector de pantalla o teclado, no pasa nada: el foco se queda en el enlace que acaba de pulsar, el lector no anuncia la página nueva y el título de la pestaña sigue diciendo lo mismo.
Tres cosas resuelven el 80 % del problema:
- Un título por ruta. Angular lo soporta de forma nativa con la propiedad
titlede la ruta (abre en una nueva ventana):
// app.routes.ts
export const routes: Routes = [
{ path: '', component: InicioComponent, title: 'Inicio · Mi trámite' },
{ path: 'radicar', component: RadicarComponent, title: 'Radicar solicitud · Mi trámite' },
];
- Mover el foco al contenido nuevo después de cada navegación:
// app.component.ts
@Component({ /* ... */ })
export class AppComponent {
private router = inject(Router);
private destroyRef = inject(DestroyRef);
constructor() {
this.router.events
.pipe(filter((e) => e instanceof NavigationEnd), takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
// el <h1> de cada página lleva tabindex="-1" para poder recibir foco por código
queueMicrotask(() => document.querySelector<HTMLElement>('main h1')?.focus());
});
}
}
- Un enlace “Saltar al contenido” como primer elemento enfocable del documento, que
lleve a ese mismo
<main>.
No reinventes componentes: usa el CDK
El error más caro que he visto en proyectos Angular es construir a mano un modal, un menú
desplegable o unas pestañas con <div (click)>. Se ven bien, pero no se pueden usar con
teclado y el lector de pantalla no sabe qué son. El paquete @angular/cdk/a11y resuelve las
partes difíciles (documentación del CDK a11y (abre en una nueva ventana)):
cdkTrapFocus: atrapa el foco dentro de un diálogo mientras está abierto. ConcdkTrapFocusAutoCapturelo mueve adentro al abrir y lo devuelve al botón que lo abrió al cerrar.FocusKeyManager/ActiveDescendantKeyManager: navegación con flechas en listas, menús y listboxes, sin escribir el manejo de teclado a mano.LiveAnnouncer: anuncia mensajes al lector de pantalla (“Solicitud enviada”, “3 resultados”) sin mover el foco.FocusMonitor: distingue si el foco llegó por teclado o por mouse, para mostrar el anillo de foco solo cuando sirve (sin quitarlo nunca del todo).
@Component({
selector: 'app-buscador',
template: `
<label for="q">Buscar trámite</label>
<input id="q" type="search" (input)="buscar($any($event.target).value)" />
<ul>
@for (r of resultados(); track r.id) {
<li><a [routerLink]="r.ruta">{{ r.nombre }}</a></li>
}
</ul>
`,
})
export class BuscadorComponent {
private announcer = inject(LiveAnnouncer);
resultados = signal<Tramite[]>([]);
buscar(q: string) {
this.resultados.set(filtrar(q));
this.announcer.announce(`${this.resultados().length} resultados`, 'polite');
}
}
Y si ya usas Angular Material, buena parte de esto viene incluido: sus componentes están construidos sobre el CDK y siguen los patrones de WAI-ARIA (abre en una nueva ventana).
HTML semántico primero, ARIA después
La primera regla de ARIA (abre en una nueva ventana) es no usar ARIA si existe un elemento nativo que haga lo mismo. En plantillas Angular eso se traduce en:
<button type="button">para acciones,<a>(orouterLink) para navegación. Nunca un<div (click)>.- Cada
<input>con su<label for>; los errores de validación enlazados conaria-describedbyy anunciados cuando aparecen. - Atributos dinámicos con el binding de atributo:
[attr.aria-expanded]="abierto()",[attr.aria-invalid]="control.invalid && control.touched".
<button type="button" [attr.aria-expanded]="abierto()" aria-controls="filtros"
(click)="abierto.set(!abierto())">
Filtros
</button>
<div id="filtros" [hidden]="!abierto()">…</div>
WCAG 2.2: los criterios que más rompen las SPAs
WCAG 2.2 añadió criterios que golpean justo a los patrones típicos de una app Angular:
- 2.4.11 Foco no oculto (mínimo) (abre en una nueva ventana), nivel AA: los headers fijos tapan el elemento
enfocado al tabular. Se arregla con
scroll-padding-topen el contenedor de scroll. - 2.5.8 Tamaño del objetivo (mínimo) (abre en una nueva ventana), nivel AA: botones e íconos clicables de al menos 24×24 px (o con espacio suficiente alrededor).
- 3.3.8 Autenticación accesible (mínimo) (abre en una nueva ventana), nivel AA: nada de obligar a resolver acertijos o a transcribir códigos sin permitir pegar o usar el gestor de contraseñas.
Cómo auditar esto paso a paso es justo lo que cubre el post de A11Y PRO (abre en una nueva ventana).
Que no se rompa: lint y tests
Lo que no se verifica en CI se degrada en dos sprints. Dos capas baratas:
1. Reglas de accesibilidad en el linter de plantillas. angular-eslint (abre en una nueva ventana) trae un conjunto
listo (templateAccessibility) que detecta, entre otras cosas, imágenes sin alt, clics sin
evento de teclado, controles sin etiqueta, tabindex positivos y ARIA inválido:
// eslint.config.js
const angular = require('angular-eslint');
const tseslint = require('typescript-eslint');
module.exports = tseslint.config({
files: ['**/*.html'],
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
});
2. axe-core (abre en una nueva ventana) en los tests de extremo a extremo (por ejemplo con
Playwright y @axe-core/playwright (abre en una nueva ventana)), que falla el pipeline si una página nueva introduce violaciones:
test('radicar solicitud no tiene violaciones de accesibilidad', async ({ page }) => {
await page.goto('/radicar');
const { violations } = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag22aa']).analyze();
expect(violations).toEqual([]);
});
Ninguna herramienta automática detecta todo: se estima que encuentran apenas una parte de los problemas reales. El resto exige probar con teclado y con un lector de pantalla de verdad (NVDA, VoiceOver, TalkBack). Esa es la frontera donde termina este post y empieza el de A11Y PRO (abre en una nueva ventana).
Checklist para tu próximo componente
Sigue por aquí
- El post complementario en A11Y PRO: cómo auditar y validar componentes Angular según WCAG 2.2. Mientras tanto, todo su trabajo está en a11ypro.tech (abre en una nueva ventana).
- Herramientas de A11Y PRO: un escáner de accesibilidad (abre en una nueva ventana), un widget (abre en una nueva ventana) y documentación con tutoriales (abre en una nueva ventana).
- Si trabajas para el Estado colombiano: el layout de GOV.CO (abre en una nueva ventana) es el punto de partida obligado; todo lo de este post aplica encima de él.
juferoga@portfolio:~/blog$ echo "accesible no es un feature, es un requisito"
accesible no es un feature, es un requisito