← ~/blog Colaboración con A11Y PROA collaboration with A11Y PRO (abre en una nueva ventana)(opens in a new window)

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:

  1. Un título por ruta. Angular lo soporta de forma nativa con la propiedad title de 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' },
];
  1. 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());
      });
  }
}
  1. 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. Con cdkTrapFocusAutoCapture lo 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> (o routerLink) para navegación. Nunca un <div (click)>.
  • Cada <input> con su <label for>; los errores de validación enlazados con aria-describedby y 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:

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í

juferoga@portfolio:~/blog$ echo "accesible no es un feature, es un requisito"
accesible no es un feature, es un requisito