Módulo 21: Angular Aria y accesibilidad

Objetivos del módulo

  • Entender qué es la accesibilidad web, a quién afecta y por qué es también una obligación legal
  • Distinguir el DOM del árbol de accesibilidad, y saber qué son el rol, el nombre y el estado de un elemento
  • Aplicar las reglas básicas de ARIA y usar primero el HTML semántico
  • Enlazar atributos ARIA desde las plantillas de Angular
  • Conocer las dos técnicas de foco en componentes compuestos: roving tabindex y aria-activedescendant
  • Instalar @angular/aria y decidir cuándo usarlo frente a HTML nativo, CDK o Material
  • Construir pestañas, acordeones, listas, combobox, árboles, menús, barras de herramientas y cuadrículas con datos de EMPRESA
  • Gestionar el foco: enlace de salto, cambios de ruta, diálogos y elementos que desaparecen
  • Anunciar cambios a los lectores de pantalla con LiveAnnouncer y cdkAriaLive
  • Escribir formularios con errores accesibles, y cuidar el contraste y el movimiento
  • Auditar con axe en Playwright y en Vitest, y conocer lo que ninguna herramienta detecta

🔄 Cambia en Angular 22.0. @angular/aria es estable desde esta versión. En la LTS (Angular 21) existe, pero está en developer preview y su API es distinta en varios puntos. Si usas la LTS: instala @angular/aria@21 y @angular/cdk@21; pestañas y acordeón se escriben igual, y lista, combobox, árbol, menú y barra de herramientas llevan su propia marca con los cambios.


1. Qué es la accesibilidad y por qué te afecta

Una aplicación es accesible cuando la puede usar cualquier persona, sea cual sea la forma en que percibe la pantalla o maneja el ordenador. Piensa en estas situaciones:

  • Una persona ciega escucha la página con un lector de pantalla (NVDA o JAWS en Windows, VoiceOver en macOS e iOS, TalkBack en Android).
  • Una persona con movilidad reducida en las manos no usa ratón: navega solo con el teclado, con un conmutador o con la voz.
  • Una persona con baja visión amplía la página al 200 % o más, o necesita más contraste.
  • Una persona con epilepsia o con trastornos vestibulares puede sentirse mal con animaciones intensas.
  • Tú mismo, con el brazo escayolado, a pleno sol en el móvil o con el ratón roto.

Lo último no es una broma. Las mejoras de accesibilidad ayudan a mucha más gente que aquella para la que se pensaron: un buen orden de foco acelera a quien rellena formularios a diario, y un buen contraste se agradece en cualquier pantalla.

Además, es una cuestión legal. La Directiva Europea de Accesibilidad (European Accessibility Act) cubre, entre otros, el comercio electrónico y los servicios bancarios, y los Estados miembros debían incorporarla a su legislación a más tardar en junio de 2022. Si trabajas en una tienda en línea o en un banco de la Unión Europea, este módulo forma parte de tu trabajo.

WCAG: la referencia común

Las WCAG (Web Content Accessibility Guidelines) son las pautas del W3C que usan las leyes y las auditorías. La versión actual es la 2.2, y se organiza en cuatro principios:

Principio Pregunta Ejemplo
Perceptible ¿Se puede percibir? Texto alternativo en imágenes, contraste suficiente
Operable ¿Se puede manejar? Todo funciona con teclado, foco visible
Comprensible ¿Se entiende? Errores claros, comportamiento predecible
Robusto ¿Funciona con distintas tecnologías? Nombre, rol y valor correctos

Cada principio se divide en criterios con un nivel: A, AA o AAA. El objetivo habitual de un proyecto profesional es el nivel AA. Estos son los criterios que más vas a tocar en Angular:

Criterio Nivel Qué exige
1.4.3 Contraste mínimo AA Texto con contraste de al menos 4,5:1; 3:1 si es grande
1.4.10 Reajuste AA El contenido funciona sin desplazamiento en dos dimensiones
1.4.11 Contraste no textual AA Controles y gráficos con al menos 3:1 frente a lo adyacente
2.1.1 Teclado A Toda la funcionalidad se maneja con teclado
2.1.2 Sin trampas de teclado A El foco siempre puede salir de un componente con el teclado
2.4.1 Evitar bloques A Hay forma de saltar el contenido repetido
2.4.2 Página titulada A Cada página tiene un título descriptivo
2.4.7 Foco visible AA Se ve dónde está el foco
4.1.2 Nombre, rol y valor A Los controles exponen esa información
4.1.3 Mensajes de estado AA Los avisos llegan al lector de pantalla sin mover el foco

2. Rol, nombre y estado: el árbol de accesibilidad

El navegador no le entrega el DOM al lector de pantalla. A partir de él calcula otra estructura, el árbol de accesibilidad, que contiene solo lo que importa para usar la página. Cada nodo tiene tres datos fundamentales:

  • Rol: qué es (botón, enlace, casilla, pestaña).
  • Nombre accesible: cómo se llama (el texto del botón, la etiqueta del campo).
  • Estado o valor: cómo está (marcado, expandido, desactivado).

El lector de pantalla lee esos datos en voz alta, y nada más. Lo que esté solo en el CSS, como un color o un icono, no llega.

Tres columnas: el DOM con un button y un div, el árbol de accesibilidad con rol, nombre y estado del botón y datos vacíos del div, y lo que dice el lector en cada caso

Figura 1. Un button y un div pueden verse igual, pero solo el primero llega al usuario como un control.

📘 Concepto: Un <div (click)="..."> tiene aspecto de botón y funciona con el ratón, pero para la tecnología de apoyo es texto suelto: sin rol, sin foco con Tab y sin reacción a Intro o Espacio. Angular no lo arregla por ti, porque el problema está en el HTML que escribes.

Puedes ver este árbol en Chrome: abre DevTools, ve a Elements y busca el panel Accessibility, que muestra el rol y el nombre calculados del elemento elegido.


3. HTML semántico primero

La especificación Using ARIA del W3C abre con esta regla:

📘 Concepto: Primera regla de ARIA. Si puedes usar un elemento o atributo HTML nativo que ya incluya la semántica y el comportamiento que necesitas, úsalo en lugar de añadir ARIA a otro elemento. La segunda regla dice: no cambies la semántica nativa salvo que sea imprescindible.

La razón es práctica. ARIA solo cambia lo que se anuncia; no aporta comportamiento. Si pones role="button" a un div, el lector dirá «botón», pero el div seguirá sin recibir el foco y sin responder al teclado. Tendrás que programar todo eso a mano, y además estarás prometiendo algo que no cumples.

Compara un formulario mal hecho con su versión correcta. Este es el malo:

// src/app/malo-bueno/malo.ts
import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-malo',
  template: `
    <div class="titulo">Alta de empleado</div>
    <img src="/favicon.ico" />
    <div>
      <input placeholder="Correo" [class.mal]="error()" />
      <span class="mal-texto">{{ error() ? 'Falta el correo' : '' }}</span>
    </div>
    <div class="boton" (click)="error.set(true)">Guardar</div>
    <a href="#"><span class="gris">Más información</span></a>
  `,
  styles: `
    .titulo {
      font-size: 2rem;
      font-weight: bold;
    }
    .boton {
      display: inline-block;
      margin-top: 1rem;
      padding: 0.5rem 1rem;
      background: #90caf9;
      color: #fff;
      cursor: pointer;
    }
    .mal {
      border-color: red;
    }
    .mal-texto {
      color: red;
    }
    .gris {
      color: #bbb;
    }
  `,
})
export class Malo {
  protected readonly error = signal(false);
}

Formulario con un título grande, un logotipo, un campo con el texto Correo dentro, un error en rojo, un botón azul claro con letras blancas y un enlace gris

Figura 2. El formulario incorrecto parece razonable con ratón y buena vista.

Y este es el bueno, que se ve casi igual:

// src/app/malo-bueno/bueno.ts
import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-bueno',
  template: `
    <h1>Alta de empleado</h1>
    <img src="/favicon.ico" alt="" />
    <form (submit)="$event.preventDefault(); error.set(true)" novalidate>
      <label for="correo">Correo</label>
      <input
        id="correo"
        type="email"
        autocomplete="email"
        required
        [attr.aria-invalid]="error()"
        [attr.aria-describedby]="error() ? 'correo-error' : null"
      />
      @if (error()) {
        <p id="correo-error" class="mal-texto">Falta el correo</p>
      }
      <p><button type="submit">Guardar</button></p>
    </form>
    <a href="/ayuda">Más información sobre las altas</a>
  `,
  styles: `
    label {
      display: block;
    }
    button {
      padding: 0.5rem 1rem;
      background: #283593;
      color: #fff;
      border: 0;
      cursor: pointer;
    }
    button:focus-visible,
    input:focus-visible {
      outline: 3px solid #ff7043;
      outline-offset: 2px;
    }
    .mal-texto {
      color: #b71c1c;
    }
  `,
})
export class Bueno {
  protected readonly error = signal(false);
}

El mismo formulario con encabezado real, etiqueta Correo sobre el campo, error en rojo oscuro, botón azul oscuro y enlace descriptivo

Figura 3. La versión correcta: etiqueta visible, error asociado al campo, botón real y buen contraste.

Problema Por qué molesta Solución
div como título El lector no puede saltar entre encabezados <h1>
img sin alt Se lee el nombre del fichero o nada alt descriptivo, o alt="" si es decorativa
placeholder como etiqueta Desaparece al escribir <label for> visible
Error sin asociar El lector no lo relaciona con el campo aria-invalid y aria-describedby
Error solo con color Quien no distingue colores no lo ve Texto explícito
div como botón Sin foco ni teclado <button>
<a href="#"> para una acción Un enlace navega; una acción es un botón Elemento correcto y texto que se entienda fuera de contexto

💡 Consejo: Una imagen decorativa lleva alt="" y no omite el atributo. Con alt="" el lector la ignora; sin él, puede leer el nombre del fichero.

Elementos nativos que te ahorran mucho trabajo:

Necesitas Usa Te da gratis
Acción <button type="button"> Foco, Intro, Espacio, rol
Navegar <a href> o routerLink Foco, Intro, menú contextual
Secciones <header>, <nav>, <main>, <footer> Regiones por las que saltar
Lista de elección simple <select> Teclado y comportamiento nativo en el móvil
Mostrar y ocultar <details> y <summary> Estado expandido y teclado
Modal <dialog> con showModal() Foco atrapado, Escape, fondo inerte

Recuerda también <html lang="es"> en src/index.html: sin él, el lector puede leer la página con la pronunciación de otro idioma. Y usa la propiedad title de cada ruta, que viste en el módulo 10, para cumplir el criterio de página titulada.

Atributos ARIA en plantillas

Los atributos ARIA son atributos HTML, así que la forma clásica de enlazarlos desde Angular es [attr.aria-*]. También funcionan sin el prefijo attr., y las propiedades del DOM ariaLabel y similares. Este ejemplo muestra el HTML que resulta de cada forma:

<button type="button" [attr.aria-label]="nombre()" [attr.aria-pressed]="activo()">A</button>
<button type="button" [aria-label]="nombre()" [aria-pressed]="activo()">B</button>
<button type="button" [attr.aria-describedby]="ayuda()">C</button>
<button type="button" [ariaLabel]="nombre()">D</button>

Con nombre igual a 'Marcar como favorito', activo a false y ayuda a null, el DOM real es:

<button type="button" aria-label="Marcar como favorito" aria-pressed="false">A</button>
<button type="button" aria-label="Marcar como favorito" aria-pressed="false">B</button>
<button type="button">C</button>
<button type="button" aria-label="Marcar como favorito">D</button>

Fíjate en dos detalles:

  • false se escribe como el texto "false". Es justo lo que quieres en aria-pressed o aria-expanded, cuyo valor debe existir también cuando es falso.
  • null elimina el atributo. Por eso [attr.aria-describedby]="error() ? 'correo-error' : null" no deja una referencia rota cuando no hay error.

⚠️ Importante: aria-hidden="true" y role="presentation" no se ponen nunca en un elemento que pueda recibir el foco. Como dice la especificación, algunos usuarios acabarían enfocando «nada». aria-hidden sirve para iconos decorativos, nunca para un botón.


4. Teclado y foco en componentes compuestos

Un botón es sencillo: Tab llega y Espacio lo pulsa. Una lista de cien opciones no lo es. Si cada opción fuese una parada de Tab, cruzar la lista costaría cien pulsaciones. Por eso los componentes compuestos siguen esta convención, recogida en la guía de patrones ARIA del W3C (ARIA Authoring Practices Guide):

  • Tab entra en el componente y sale de él: cuenta como una sola parada.
  • Las flechas se mueven por dentro.
  • Inicio y Fin van al primero y al último.
  • Escribir letras salta al elemento que empieza por ellas (typeahead).

Para conseguirlo hay dos técnicas:

Dos paneles: a la izquierda tres pestañas en las que solo la activa tiene tabindex cero; a la derecha un campo de texto con el foco cuyo aria-activedescendant señala la opción activa

Figura 4. En roving se mueve el foco real; con aria-activedescendant el foco se queda en el campo y solo cambia a quién señala.

  • Roving tabindex. Solo el elemento activo tiene tabindex="0"; los demás tienen tabindex="-1". Al pulsar una flecha, el 0 pasa al siguiente y se le da el foco. Si sales y vuelves con Tab, regresas al último elemento activo.
  • aria-activedescendant. El foco no se mueve del contenedor o del campo de texto, que indica con ese atributo el id del elemento activo. El lector anuncia ese elemento. Es imprescindible en un combobox, porque el usuario debe poder seguir escribiendo.

Hacerlo a mano exige bastante código: gestionar tabindex, cada tecla, el orden de lectura (o el inverso, en idiomas de derecha a izquierda), los elementos desactivados y la búsqueda por letras. Y hay que repetirlo en cada componente. Ahí es donde entra Angular Aria.


5. Angular Aria: patrones sin estilos 🔄 v22.0

🔄 Cambia en Angular 22.0. @angular/aria pasa de developer preview a estable. Si usas la LTS: instala npm install @angular/aria@21 @angular/cdk@21; la API funciona, pero puede cambiar entre versiones, y de hecho cambia en 22 como indican las marcas de este módulo.

@angular/aria es una biblioteca de directivas sin estilos (en inglés, headless). Tú pones el HTML, el CSS y la lógica de negocio, y las directivas ponen los roles, los estados ARIA, el teclado y la gestión del foco. No trae ningún aspecto visual.

Cuatro filas: HTML nativo, CDK a11y, Angular Aria y Angular Material, con lo que da cada capa y lo que pones tú

Figura 5. Las cuatro capas. Empieza por la de arriba y baja solo cuando la anterior no te sirva.

La documentación oficial recomienda Angular Aria para crear sistemas de diseño o bibliotecas de componentes propias, y cuando la marca exige un aspecto que no encaja en una biblioteca con estilos. Para lo demás:

Situación Elección
Un formulario sencillo con un desplegable normal HTML nativo
Aplicación interna que debe salir rápido Angular Material
Pestañas, combobox o árbol con diseño corporativo propio Angular Aria
Una pieza suelta: anunciar, atrapar el foco CDK a11y (sección 12)

Instalación

npm install @angular/aria @angular/cdk

No hace falta ningún provider. Cada patrón se importa desde su propio punto de entrada, y cada directiva se añade a imports del componente:

Punto de entrada Directivas
@angular/aria/tabs Tabs, TabList, Tab, TabPanel, TabContent
@angular/aria/accordion AccordionGroup, AccordionTrigger, AccordionPanel, AccordionContent
@angular/aria/listbox Listbox, Option
@angular/aria/combobox Combobox, ComboboxPopup, ComboboxWidget
@angular/aria/tree Tree, TreeItem, TreeItemGroup
@angular/aria/menu Menu, MenuBar, MenuItem, MenuTrigger, MenuContent
@angular/aria/toolbar Toolbar, ToolbarWidget, ToolbarWidgetGroup
@angular/aria/grid Grid, GridRow, GridCell, GridCellWidget

Opciones que se repiten

Casi todos los patrones comparten las mismas entradas. Estos son sus valores por defecto, leídos del código de @angular/aria 22.2:

Entrada Efecto Tabs Accordion Listbox Tree Toolbar Grid
softDisabled Los desactivados reciben el foco, pero no actúan true true true true true true
wrap Del último se pasa al primero true false true true true No aplica
selectionMode follow: seleccionar al mover. explicit: con Intro o Espacio follow No aplica follow explicit No aplica follow
focusMode roving o activedescendant roving No aplica roving roving No aplica roving
orientation Qué flechas se usan horizontal No aplica vertical vertical horizontal No aplica

📘 Concepto: softDisabled es verdadero por defecto, y tiene su lógica: un elemento desactivado que el teclado se salta es invisible para quien usa lector de pantalla, que nunca sabrá que existe. Con foco suave lo oye anunciado como «no disponible». Pon [softDisabled]="false" si prefieres saltarlo.

El estilo se engancha a los atributos

Como las directivas escriben los atributos ARIA en el DOM, tu CSS se apoya en ellos. Así no puede haber un estado visual distinto del anunciado:

[ngTab][aria-selected='true'] { /* pestaña elegida */ }
[ngTab][aria-disabled='true'] { /* pestaña desactivada */ }
[ngOption][data-active='true'] { /* opción activa en modo activedescendant */ }
[ngTabPanel][inert] { display: none; } /* panel oculto */

6. Pestañas con ngTabs 🔄 v22.0

🔄 Cambia en Angular 22.0. Las directivas de pestañas pasan de developer preview a estables, sin cambios en su API. Si usas la LTS: este apartado se escribe igual con @angular/aria@21; lo hemos compilado y ejecutado en 21.2.

Vamos a mostrar la ficha de un empleado en tres pestañas. El salario queda desactivado si el usuario no tiene permiso.

// src/app/ficha-empleado/ficha-empleado.ts
import { CurrencyPipe } from '@angular/common';
import { httpResource } from '@angular/common/http';
import { Component, input, signal } from '@angular/core';
import { Tab, TabContent, TabList, TabPanel, Tabs } from '@angular/aria/tabs';
import { API } from '../api';
import { Empleado } from '../modelos';

@Component({
  selector: 'app-ficha-empleado',
  imports: [CurrencyPipe, Tab, TabContent, TabList, TabPanel, Tabs],
  template: `
    @if (empleado.value(); as e) {
      <h1>{{ e.nombre }} {{ e.apellidos }}</h1>

      <div ngTabs class="pestanas">
        <ul ngTabList [(selectedTab)]="pestana" aria-label="Datos del empleado">
          <li ngTab value="contacto">Contacto</li>
          <li ngTab value="puesto">Puesto</li>
          <li ngTab value="salario" [disabled]="!veSalarios()">Salario</li>
        </ul>

        <div ngTabPanel value="contacto">
          <ng-template ngTabContent>
            <p>Correo: <a [href]="'mailto:' + e.email">{{ e.email }}</a></p>
          </ng-template>
        </div>
        <div ngTabPanel value="puesto">
          <ng-template ngTabContent>
            <p>Oficio: {{ e.oficio }}</p>
            <p>Departamento: {{ e.deptId }}</p>
          </ng-template>
        </div>
        <div ngTabPanel value="salario">
          <ng-template ngTabContent>
            <p>Salario: {{ e.salario | currency: 'EUR' }}</p>
          </ng-template>
        </div>
      </div>

      <p class="estado">Pestaña activa: {{ pestana() }}</p>
    }
  `,
  styles: `
    .pestanas [ngTabList] {
      display: flex;
      gap: 0.25rem;
      margin: 0;
      padding: 0;
      list-style: none;
      border-bottom: 2px solid #c5cae9;
    }
    .pestanas [ngTab] {
      padding: 0.5rem 1rem;
      cursor: pointer;
      border-radius: 6px 6px 0 0;
    }
    .pestanas [ngTab][aria-selected='true'] {
      background: #283593;
      color: #fff;
    }
    .pestanas [ngTab][aria-disabled='true'] {
      color: #5f6b73;
      cursor: not-allowed;
    }
    .pestanas [ngTab]:focus-visible {
      outline: 3px solid #ff7043;
      outline-offset: 2px;
    }
    .pestanas [ngTabPanel] {
      padding: 1rem;
    }
    .pestanas [ngTabPanel]:focus-visible {
      outline: 3px solid #ff7043;
      outline-offset: -3px;
    }
    .pestanas [ngTabPanel][inert] {
      display: none;
    }
  `,
})
export class FichaEmpleado {
  readonly id = input(100);
  readonly veSalarios = input(false);

  protected readonly pestana = signal<string | undefined>('contacto');
  protected readonly empleado = httpResource<Empleado>(() => `${API}/empleados/${this.id()}`);
}

Ficha de Ana García López con tres pestañas; Puesto está seleccionada con fondo azul y foco naranja, Salario aparece atenuada y debajo se muestra el oficio

Figura 6. Pestañas con el diseño que tú elijas. El teclado y los atributos los pone la directiva.

Fíjate en cómo se conectan las piezas: el value de cada ngTab coincide con el de su ngTabPanel. selectedTab es un model(), así que [(selectedTab)] lo enlaza en las dos direcciones con una señal.

Lo que escribe la directiva

Este es el DOM real, resumido, de la lista, de dos pestañas y del panel activo. Salvo el aria-label, que es tuyo, todo lo ha añadido Angular Aria:

<ul role="tablist" aria-label="Datos del empleado" tabindex="-1"
    aria-disabled="false" aria-orientation="horizontal">
<li role="tab" data-active="true" id="ng-tab-a66306-0" tabindex="0"
    aria-selected="true" aria-disabled="false" aria-controls="ng-tabpanel-a66306-0">
<li role="tab" data-active="false" id="ng-tab-a66306-1" tabindex="-1"
    aria-selected="false" aria-disabled="false" aria-controls="ng-tabpanel-a66306-1">
<div role="tabpanel" id="ng-tabpanel-a66306-0" tabindex="0"
     aria-labelledby="ng-tab-a66306-0">

Reconoces el roving tabindex: un solo tabindex="0". Los id y las relaciones aria-controls y aria-labelledby se generan solos, y evitas uno de los errores más habituales, el de las referencias rotas.

Teclado

Tecla Efecto
Flecha derecha o izquierda Mueve el foco; en modo follow, también selecciona
Inicio, Fin Primera y última pestaña
Intro, Espacio Selecciona la pestaña con el foco
Tab Sale de la lista y entra en el panel, que tiene tabindex="0"

Al probarlo sobre la ficha ocurre esto, con veSalarios a false:

Foco en Contacto
Flecha derecha  -> foco en Puesto,  pestaña activa: puesto
Flecha derecha  -> foco en Salario, pestaña activa: puesto (desactivada: no se selecciona)
Inicio          -> foco en Contacto, pestaña activa: contacto

follow o explicit

follow selecciona al moverse, lo que resulta cómodo si los paneles se muestran al instante. Si cambiar de pestaña lanza una petición lenta, usa selectionMode="explicit": el usuario recorre las pestañas sin provocar cargas, y elige con Intro o Espacio.

Contenido perezoso y preserveContent

Lo que va dentro de ng-template ngTabContent se crea la primera vez que hace falta. Pero ten cuidado: al cambiar de pestaña, el panel que se oculta se queda vacío. Medido en la ficha tras ir a Puesto, los paneles tienen estos hijos:

Contacto: inert, 0 hijos
Puesto:   visible, 2 hijos
Salario:  inert, 0 hijos

Es decir, un campo escrito en una pestaña se pierde al volver. Para conservarlo, añade [preserveContent]="true" al panel. El ejercicio 2 lo practica.

⚠️ Importante: Escribe [preserveContent]="true" con corchetes. Sin ellos (preserveContent) Angular pasa una cadena vacía y el compilador lo rechaza: TS2322: Type 'string' is not assignable to type 'boolean'.

⚠️ Importante: El CSS [ngTabPanel][inert] { display: none; } es obligatorio. La directiva pone inert, que quita el panel del árbol de accesibilidad y del foco, pero no lo oculta visualmente.


7. Acordeón 🔄 v22.0

🔄 Cambia en Angular 22.0. Las directivas de acordeón pasan de developer preview a estables, sin cambios en su API. Si usas la LTS: este apartado se escribe igual con @angular/aria@21; lo hemos compilado y ejecutado en 21.2.

Un acordeón es una lista de encabezados que muestran u ocultan su contenido. Si solo necesitas uno suelto, <details> nativo basta. Angular Aria aporta la navegación con flechas entre encabezados y la opción de abrir uno solo a la vez.

// src/app/ayuda-rrhh/ayuda-rrhh.ts
import { Component, viewChild } from '@angular/core';
import {
  AccordionContent,
  AccordionGroup,
  AccordionPanel,
  AccordionTrigger,
} from '@angular/aria/accordion';

@Component({
  selector: 'app-ayuda-rrhh',
  imports: [AccordionContent, AccordionGroup, AccordionPanel, AccordionTrigger],
  template: `
    <h1>Ayuda de recursos humanos</h1>

    <p>
      <button type="button" (click)="grupo().expandAll()">Abrir todo</button>
      <button type="button" (click)="grupo().collapseAll()">Cerrar todo</button>
    </p>

    <div ngAccordionGroup [multiExpandable]="true" class="acordeon">
      <h2>
        <button ngAccordionTrigger [panel]="vacaciones" [expanded]="true">
          ¿Cómo pido vacaciones?
        </button>
      </h2>
      <div ngAccordionPanel #vacaciones="ngAccordionPanel">
        <ng-template ngAccordionContent>
          <p>Entra en tu ficha y solicítalas con quince días de antelación.</p>
        </ng-template>
      </div>

      <h2>
        <button ngAccordionTrigger [panel]="nominas">¿Dónde veo mis nóminas?</button>
      </h2>
      <div ngAccordionPanel #nominas="ngAccordionPanel">
        <ng-template ngAccordionContent>
          <p>En la pestaña Salario de tu ficha, si tienes permiso.</p>
        </ng-template>
      </div>

      <h2>
        <button ngAccordionTrigger [panel]="baja" disabled>¿Cómo comunico una baja?</button>
      </h2>
      <div ngAccordionPanel #baja="ngAccordionPanel">
        <ng-template ngAccordionContent>
          <p>Contenido aún no disponible.</p>
        </ng-template>
      </div>
    </div>
  `,
  styles: `
    .acordeon h2 {
      margin: 0;
      font-size: 1rem;
    }
    .acordeon [ngAccordionTrigger] {
      width: 100%;
      padding: 0.75rem 1rem;
      text-align: left;
      font: inherit;
      background: #e8eaf6;
      border: 1px solid #3f51b5;
      cursor: pointer;
    }
    .acordeon [ngAccordionTrigger]::before {
      content: '+ ';
    }
    .acordeon [ngAccordionTrigger][aria-expanded='true']::before {
      content: '- ';
    }
    .acordeon [ngAccordionTrigger][aria-disabled='true'] {
      color: #5f6b73;
      cursor: not-allowed;
    }
    .acordeon [ngAccordionTrigger]:focus-visible {
      outline: 3px solid #ff7043;
      outline-offset: 2px;
    }
    .acordeon [ngAccordionPanel] {
      padding: 0 1rem;
    }
    .acordeon [ngAccordionPanel][inert] {
      display: none;
    }
  `,
})
export class AyudaRrhh {
  protected readonly grupo = viewChild.required(AccordionGroup);
}

Acordeón con tres encabezados: los dos primeros abiertos con su texto debajo y el tercero cerrado y atenuado

Figura 7. Acordeón con varios paneles abiertos. El tercero está desactivado.

Puntos importantes:

  • El disparador es un <button> real dentro de un encabezado (<h2>). Así el lector puede navegar por encabezados, y el botón aporta el foco y el teclado nativos.
  • #vacaciones="ngAccordionPanel" guarda una referencia a la directiva, y [panel] conecta con ella el disparador.
  • La directiva pone aria-expanded y aria-controls al botón, y role="region" y aria-labelledby al panel.
  • expanded es un model(): [expanded]="true" deja el panel abierto al inicio, y [(expanded)] lo sincroniza con una señal.
  • AccordionGroup ofrece expandAll() y collapseAll(), y viewChild te da acceso a ellos.
Tecla Efecto
Flecha arriba o abajo Mueve el foco entre encabezados
Inicio, Fin Primer y último encabezado
Intro, Espacio Abre o cierra

Dos comportamientos que conviene comprobar por ti mismo:

  • multiExpandable es true por defecto. Para permitir un solo panel abierto debes poner [multiExpandable]="false". En ese modo expandAll() no hace nada.
  • wrap es false. A diferencia de las pestañas, en el último encabezado la flecha abajo no vuelve al primero.

El tercer encabezado está desactivado, pero recibe el foco porque softDisabled es true. Intro no lo abre. Y «Abrir todo» tampoco: en la prueba quedan abiertos dos paneles, no tres.


8. Listas de selección con ngListbox 🔄 v22.0

🔄 Cambia en Angular 22.0. Listbox pasa a estable y su modelo de selección se llama value en lugar de values. Si usas la LTS: escribe [(values)]="seleccionados"; el resto de este apartado funciona igual, y lo hemos ejecutado en 21.2.

Un listbox es una lista de opciones que permanece a la vista. Si te basta con un desplegable, usa <select>. Tiene sentido cuando quieres ver varias opciones a la vez, elegir varias o darles un diseño propio.

// src/app/selector-departamentos/selector-departamentos.ts
import { httpResource } from '@angular/common/http';
import { Component, computed, signal } from '@angular/core';
import { Listbox, Option } from '@angular/aria/listbox';
import { API } from '../api';
import { Departamento, Pagina } from '../modelos';

@Component({
  selector: 'app-selector-departamentos',
  imports: [Listbox, Option],
  template: `
    <h1 id="titulo-deptos">Departamentos para el informe</h1>

    <ul
      ngListbox
      class="lista"
      aria-labelledby="titulo-deptos"
      [multi]="true"
      selectionMode="explicit"
      [(value)]="seleccionados"
    >
      @for (d of departamentos(); track d.deptId) {
        <li ngOption [value]="d.deptId" [label]="d.nombre">
          <span aria-hidden="true">{{ seleccionados().includes(d.deptId) ? '✓' : ' ' }}</span>
          {{ d.nombre }} ({{ d.localidad }})
        </li>
      }
    </ul>

    <p class="estado">Seleccionados: {{ seleccionados().join(', ') || 'ninguno' }}</p>
  `,
  styles: `
    .lista {
      width: 20rem;
      margin: 0;
      padding: 0;
      list-style: none;
      border: 1px solid #3f51b5;
    }
    .lista [ngOption] {
      padding: 0.4rem 0.75rem;
      cursor: pointer;
    }
    .lista [ngOption] span {
      display: inline-block;
      width: 1rem;
    }
    .lista [ngOption][aria-selected='true'] {
      background: #c5cae9;
    }
    .lista [ngOption]:focus-visible {
      outline: 3px solid #ff7043;
      outline-offset: -3px;
    }
  `,
})
export class SelectorDepartamentos {
  private readonly pagina = httpResource<Pagina<Departamento>>(() => `${API}/departamentos`);

  protected readonly departamentos = computed(() => this.pagina.value()?.contenido ?? []);
  protected readonly seleccionados = signal<number[]>([]);
}

Lista de siete departamentos con su localidad, todos marcados con una señal y resaltados, y el foco en Informática

Figura 8. Tras pulsar Ctrl+A se seleccionan todas las opciones.

La lista recibe y publica un array, aunque la selección sea simple. Con multi en false tendrá cero o un elemento. El label de cada opción sirve para la búsqueda por letras. La marca ✓ lleva aria-hidden="true" porque el estado ya se anuncia mediante aria-selected; si no, el lector lo diría dos veces.

Con [multi]="true" y selectionMode="explicit", esto es lo que hace el teclado:

Tecla Efecto Ejemplo real
Flechas Mueven el foco sin seleccionar  
Espacio Marca o desmarca Seleccionados: 10, 30
Letra Salta a la opción que empieza por ella v lleva a VENTAS
Mayús + flecha Amplía la selección Seleccionados: 10, 30, 20
Ctrl + A Selecciona todas Seleccionados: 10, 30, 20, 40, 50, 60, 70

Observa que value conserva el orden en que se eligieron, no el de la lista. Si te importa, ordena el array antes de usarlo.


9. Combobox y autocompletado 🔄 v22.0

🔄 Cambia en Angular 22.0. Combobox es estable y tiene otra estructura: ngCombobox va directamente en el input, y el popup usa ngComboboxPopup y ngComboboxWidget. Si usas la LTS: envuelve el input en <div ngCombobox filterMode="manual">, marca el campo con ngComboboxInput y usa <ng-template ngComboboxPopupContainer>, como en el código del final de este apartado.

Un combobox es un campo que abre una lista de sugerencias. Es el patrón más delicado de todos: el foco real debe quedarse en el campo para poder seguir escribiendo, mientras las flechas recorren una lista que aparece y desaparece. Por eso usa aria-activedescendant.

Vamos a buscar empleados en la API EMPRESA según se escribe. Necesita tres piezas:

  • ngCombobox, en el input. Gestiona expanded y el texto (value).
  • ngComboboxPopup, en un ng-template. Se renderiza cuando el combobox se expande.
  • ngComboboxWidget, sobre la lista. Comunica su id y el elemento activo.
// src/app/buscador-empleados/buscador-empleados.ts
import { httpResource } from '@angular/common/http';
import { Component, computed, effect, inject, signal, untracked, viewChild } from '@angular/core';
import { LiveAnnouncer } from '@angular/cdk/a11y';
import { Combobox, ComboboxPopup, ComboboxWidget } from '@angular/aria/combobox';
import { Listbox, Option } from '@angular/aria/listbox';
import { API } from '../api';
import { Empleado, Pagina } from '../modelos';

@Component({
  selector: 'app-buscador-empleados',
  imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option],
  template: `
    <h1>Buscar empleado</h1>

    <div class="buscador">
      <label for="busqueda">Empleado</label>
      <input
        id="busqueda"
        ngCombobox
        #combobox="ngCombobox"
        [(value)]="texto"
        [(expanded)]="abierto"
        placeholder="Escribe un nombre"
      />

      <ng-template ngComboboxPopup [combobox]="combobox">
        <ul
          ngListbox
          ngComboboxWidget
          #lista="ngListbox"
          class="sugerencias"
          aria-label="Empleados encontrados"
          focusMode="activedescendant"
          selectionMode="explicit"
          [activeDescendant]="lista.activeDescendant()"
          [(value)]="seleccion"
          (click)="confirmar()"
          (keydown.enter)="confirmar()"
        >
          @for (e of empleados(); track e.empId) {
            <li ngOption [value]="e.empId" [label]="e.nombre + ' ' + e.apellidos">
              {{ e.nombre }} {{ e.apellidos }}
            </li>
          }
        </ul>
      </ng-template>
    </div>

    <p class="estado">Elegido: {{ elegido() ?? 'nadie' }}</p>
  `,
  styles: `
    .buscador {
      position: relative;
      width: 20rem;
    }
    .buscador label {
      display: block;
      margin-bottom: 0.25rem;
    }
    .buscador input {
      box-sizing: border-box;
      width: 100%;
      padding: 0.5rem;
      font: inherit;
    }
    .sugerencias {
      position: absolute;
      z-index: 1;
      box-sizing: border-box;
      width: 100%;
      max-height: 14rem;
      overflow: auto;
      margin: 0;
      padding: 0;
      list-style: none;
      background: #fff;
      border: 1px solid #3f51b5;
    }
    .sugerencias:empty {
      display: none;
    }
    .sugerencias [ngOption] {
      padding: 0.4rem 0.75rem;
      cursor: pointer;
    }
    .sugerencias [ngOption][data-active='true'] {
      background: #e8eaf6;
      outline: 2px solid #ff7043;
      outline-offset: -2px;
    }
    .sugerencias [ngOption][aria-selected='true'] {
      background: #c5cae9;
    }
  `,
})
export class BuscadorEmpleados {
  private readonly anunciador = inject(LiveAnnouncer);
  private readonly comboboxRef = viewChild.required(Combobox);

  protected readonly texto = signal('');
  protected readonly abierto = signal(false);
  protected readonly seleccion = signal<number[]>([]);
  protected readonly elegido = signal<string | undefined>(undefined);

  private readonly resultado = httpResource<Pagina<Empleado>>(() => ({
    url: `${API}/empleados`,
    params: { q: this.texto(), tamano: 8 },
  }));

  protected readonly empleados = computed(() =>
    this.resultado.hasValue() ? this.resultado.value().contenido : [],
  );

  constructor() {
    effect(() => {
      if (this.resultado.isLoading() || !this.abierto()) {
        return;
      }
      const total = this.empleados().length;
      untracked(() =>
        this.anunciador.announce(
          total === 0 ? 'Sin resultados' : `${total} resultados. Usa las flechas para recorrerlos.`,
        ),
      );
    });
  }

  protected confirmar() {
    const empleado = this.empleados().find((e) => e.empId === this.seleccion()[0]);
    if (!empleado) {
      return;
    }
    const nombre = `${empleado.nombre} ${empleado.apellidos}`;
    this.texto.set(nombre);
    this.elegido.set(nombre);
    this.abierto.set(false);
    this.comboboxRef().element.focus();
  }
}

Campo Empleado con el texto ar y una lista de sugerencias debajo con la tercera opción resaltada

Figura 9. El foco real sigue en el campo, y la opción resaltada es la que señala aria-activedescendant.

Al escribir ar y pulsar la flecha abajo, el DOM del campo queda así:

<input role="combobox" id="busqueda" aria-autocomplete="list" aria-expanded="true"
       aria-haspopup="listbox" aria-controls="ng-listbox-a19705-0"
       aria-activedescendant="ng-option-a19705-9" tabindex="0">

Y el flujo completo se comporta así:

Acción Resultado
Escribir ar Se abre la lista con 8 opciones; document.activeElement sigue siendo el input
Flecha abajo Cambia aria-activedescendant; el foco no se mueve
Intro El campo muestra el nombre, aria-expanded pasa a false y el foco sigue en el campo
Escape Se cierra la lista y se conserva lo escrito
Clic en una opción Igual que Intro
Escribir zzzz No hay opciones, y se anuncia «Sin resultados»

Algunos detalles merecen una explicación:

  • Tú decides qué se filtra. Aquí filtra la API, pero podrías usar un computed sobre una lista local, como en el ejercicio 5. ngCombobox no filtra nada: solo muestra lo que tú pongas en el popup.
  • confirmar lo pones tú. La lista no cierra el combobox: tú enlazas (click) y (keydown.enter), y tú cierras con abierto.set(false).
  • selectionMode="explicit" evita elegir a alguien por el simple hecho de pasar por encima con las flechas.
  • El empty. El CSS .sugerencias:empty oculta el recuadro cuando no hay resultados.
  • El anuncio. Un usuario que no ve la lista necesita saber cuántas sugerencias hay. Lo veremos en la sección 13.

💡 Consejo: Aquí cada pulsación hace una petición. En una aplicación real, retrásala con un buscador con debounce, como viste en el módulo 15.

Un desplegable propio

Para un select con diseño propio, el mismo patrón se aplica a un elemento no editable. Basta con poner ngCombobox en un div:

// src/app/select-departamento/select-departamento.ts
import { httpResource } from '@angular/common/http';
import { Component, computed, signal } from '@angular/core';
import { Combobox, ComboboxPopup, ComboboxWidget } from '@angular/aria/combobox';
import { Listbox, Option } from '@angular/aria/listbox';
import { API } from '../api';
import { Departamento, Pagina } from '../modelos';

@Component({
  selector: 'app-select-departamento',
  imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option],
  template: `
    <h1 id="etiqueta-depto">Departamento</h1>

    <div class="select">
      <div
        ngCombobox
        #combobox="ngCombobox"
        class="disparador"
        aria-labelledby="etiqueta-depto"
        [(expanded)]="abierto"
      >
        {{ nombreElegido() }}
      </div>

      <ng-template ngComboboxPopup [combobox]="combobox">
        <ul
          ngListbox
          ngComboboxWidget
          #lista="ngListbox"
          class="opciones"
          aria-labelledby="etiqueta-depto"
          focusMode="activedescendant"
          selectionMode="explicit"
          [activeDescendant]="lista.activeDescendant()"
          [(value)]="seleccion"
          (click)="abierto.set(false)"
          (keydown.enter)="abierto.set(false)"
        >
          @for (d of departamentos(); track d.deptId) {
            <li ngOption [value]="d.deptId" [label]="d.nombre">{{ d.nombre }}</li>
          }
        </ul>
      </ng-template>
    </div>
  `,
  styles: `
    .select {
      position: relative;
      width: 16rem;
    }
    .disparador {
      padding: 0.5rem;
      border: 1px solid #3f51b5;
      cursor: pointer;
    }
    .opciones {
      position: absolute;
      z-index: 1;
      box-sizing: border-box;
      width: 100%;
      margin: 0;
      padding: 0;
      list-style: none;
      background: #fff;
      border: 1px solid #3f51b5;
    }
    .opciones [ngOption] {
      padding: 0.4rem 0.75rem;
      cursor: pointer;
    }
    .opciones [ngOption][data-active='true'] {
      outline: 2px solid #ff7043;
      outline-offset: -2px;
    }
    .opciones [ngOption][aria-selected='true'] {
      background: #c5cae9;
    }
  `,
})
export class SelectDepartamento {
  private readonly pagina = httpResource<Pagina<Departamento>>(() => `${API}/departamentos`);

  protected readonly departamentos = computed(() => this.pagina.value()?.contenido ?? []);
  protected readonly abierto = signal(false);
  protected readonly seleccion = signal<number[]>([]);
  protected readonly nombreElegido = computed(
    () =>
      this.departamentos().find((d) => d.deptId === this.seleccion()[0])?.nombre ??
      'Elige un departamento',
  );
}

Desplegable de departamentos abierto con siete opciones y Informática señalada

Figura 10. Un desplegable propio. Antes de construirlo, pregúntate si un select nativo no te bastaría.

⚠️ Importante: El popup no se cierra solo al elegir. Sin (click) y (keydown.enter) sobre la lista, Intro cambia la selección, pero aria-expanded sigue en true y la lista queda abierta.

💡 Consejo: Aquí el popup se coloca con position: absolute, lo más sencillo. Si un contenedor con overflow: hidden lo recorta, la guía oficial lo combina con cdkConnectedOverlay, del módulo 20.

El combobox en la LTS

Este es el buscador con la API de 21.2, compilado y ejecutado con Angular 21.2 y @angular/aria 21.2.14. En 21 los cambios frente a 22 son estos: la directiva ngCombobox envuelve al campo, filterMode="manual" indica que filtras tú, y es ngCombobox quien escribe el texto y cierra el popup al confirmar, tanto con Intro como con clic. Por eso no hay confirmar: te basta con escuchar valuesChange.

// Solo Angular 21: src/app/buscador-empleados/buscador-empleados.ts
import { httpResource } from '@angular/common/http';
import { Component, computed, signal } from '@angular/core';
import { Combobox, ComboboxInput, ComboboxPopupContainer } from '@angular/aria/combobox';
import { Listbox, Option } from '@angular/aria/listbox';
import { API } from '../api';
import { Empleado, Pagina } from '../modelos';

@Component({
  selector: 'app-buscador-empleados',
  imports: [Combobox, ComboboxInput, ComboboxPopupContainer, Listbox, Option],
  template: `
    <h1>Buscar empleado</h1>

    <div ngCombobox filterMode="manual" class="buscador">
      <label for="busqueda">Empleado</label>
      <input id="busqueda" ngComboboxInput [(value)]="texto" />

      <ng-template ngComboboxPopupContainer>
        <ul
          ngListbox
          class="sugerencias"
          aria-label="Empleados encontrados"
          focusMode="activedescendant"
          selectionMode="explicit"
          [values]="seleccion()"
          (valuesChange)="elegir($event)"
        >
          @for (e of empleados(); track e.empId) {
            <li ngOption [value]="e.empId" [label]="e.nombre + ' ' + e.apellidos">
              {{ e.nombre }} {{ e.apellidos }}
            </li>
          }
        </ul>
      </ng-template>
    </div>

    <p class="estado">Elegido: {{ elegido() ?? 'nadie' }}</p>
  `,
})
export class BuscadorEmpleados {
  protected readonly texto = signal('');
  protected readonly seleccion = signal<number[]>([]);
  protected readonly elegido = signal<string | undefined>(undefined);

  private readonly resultado = httpResource<Pagina<Empleado>>(() => ({
    url: `${API}/empleados`,
    params: { q: this.texto(), tamano: 8 },
  }));

  protected readonly empleados = computed(() =>
    this.resultado.hasValue() ? this.resultado.value().contenido : [],
  );

  protected elegir(valores: number[]) {
    this.seleccion.set(valores);
    const e = this.empleados().find((x) => x.empId === valores[0]);
    if (e) {
      this.elegido.set(`${e.nombre} ${e.apellidos}`);
    }
  }
}

10. Árboles 🔄 v22.0

🔄 Cambia en Angular 22.0. Tree pasa a estable y su modelo de selección se llama value en lugar de values. Si usas la LTS: escribe [(values)]="seleccion"; el resto del apartado funciona igual, y lo hemos ejecutado en 21.2.

Las categorías de EMPRESA forman una jerarquía mediante categoriaPadreId, perfecta para un árbol. Primero la transformamos en una estructura anidada, y luego la dibujamos con una plantilla que se llama a sí misma:

// src/app/arbol-categorias/arbol-categorias.ts
import { NgTemplateOutlet } from '@angular/common';
import { httpResource } from '@angular/common/http';
import { Component, computed, signal } from '@angular/core';
import { Tree, TreeItem, TreeItemGroup } from '@angular/aria/tree';
import { API } from '../api';
import { Categoria, Pagina } from '../modelos';

interface NodoCategoria {
  id: number;
  nombre: string;
  hijas: NodoCategoria[];
}

function crearArbol(categorias: Categoria[], padre: number | null = null): NodoCategoria[] {
  return categorias
    .filter((c) => c.categoriaPadreId === padre)
    .map((c) => ({
      id: c.categoriaId,
      nombre: c.nombre,
      hijas: crearArbol(categorias, c.categoriaId),
    }));
}

@Component({
  selector: 'app-arbol-categorias',
  imports: [NgTemplateOutlet, Tree, TreeItem, TreeItemGroup],
  template: `
    <h1 id="titulo-arbol">Categorías de producto</h1>

    <ul ngTree #arbol="ngTree" class="arbol" aria-labelledby="titulo-arbol" [(value)]="seleccion">
      <ng-template
        [ngTemplateOutlet]="nodos"
        [ngTemplateOutletContext]="{ lista: raices(), padre: arbol }"
      />
    </ul>

    <ng-template #nodos let-lista="lista" let-padre="padre">
      @for (nodo of lista; track nodo.id) {
        <li ngTreeItem #item="ngTreeItem" [parent]="padre" [value]="nodo.id" [label]="nodo.nombre">
          <span class="fila">{{ nodo.nombre }}</span>
          @if (nodo.hijas.length > 0) {
            <ul role="group">
              <ng-template ngTreeItemGroup [ownedBy]="item" #grupo="ngTreeItemGroup">
                <ng-template
                  [ngTemplateOutlet]="nodos"
                  [ngTemplateOutletContext]="{ lista: nodo.hijas, padre: grupo }"
                />
              </ng-template>
            </ul>
          }
        </li>
      }
    </ng-template>

    <p class="estado">Categoría elegida: {{ seleccion().at(0) ?? 'ninguna' }}</p>
  `,
  styles: `
    .arbol,
    .arbol ul {
      margin: 0;
      padding: 0;
      list-style: none;
    }
    .arbol ul {
      padding-left: 1.25rem;
    }
    .arbol .fila {
      display: block;
      padding: 0.3rem 0.5rem;
      cursor: pointer;
    }
    .arbol li[aria-expanded='false'] > .fila::before {
      content: '▸ ';
    }
    .arbol li[aria-expanded='true'] > .fila::before {
      content: '▾ ';
    }
    .arbol li[aria-expanded='false'] > ul {
      display: none;
    }
    .arbol li:not([aria-expanded]) > .fila {
      padding-left: 1.4rem;
    }
    .arbol li[aria-selected='true'] > .fila {
      background: #c5cae9;
    }
    .arbol li:focus-visible {
      outline: none;
    }
    .arbol li:focus-visible > .fila {
      outline: 3px solid #ff7043;
      outline-offset: -3px;
    }
  `,
})
export class ArbolCategorias {
  private readonly pagina = httpResource<Pagina<Categoria>>(() => `${API}/categorias`);

  protected readonly raices = computed(() => crearArbol(this.pagina.value()?.contenido ?? []));
  protected readonly seleccion = signal<number[]>([]);
}

Árbol de categorías con Informática desplegada, sus hijas Portátiles, Ordenadores sobremesa y Periféricos, y la segunda seleccionada y con el foco

Figura 11. El árbol de categorías con datos reales de la API.

Lo más raro de este código es la recursión con plantillas. Sigue el recorrido:

  1. ngTemplateOutlet dibuja la plantilla #nodos con los nodos raíz. El padre es el propio Tree, que llega en el contexto.
  2. Cada li necesita saber quién es su padre, y lo recibe por [parent].
  3. Si un nodo tiene hijas, se crea un ngTreeItemGroup, cuyo [ownedBy] apunta al li de arriba.
  4. Dentro del grupo, la misma plantilla #nodos se dibuja otra vez con las hijas, y esta vez el padre es grupo.

Tú no calculas niveles: la directiva pone aria-level, aria-setsize y aria-posinset. Este es un nodo real:

<li role="treeitem" id="ng-tree-item-a34483-0" data-active="true" aria-expanded="false"
    aria-selected="false" aria-disabled="false" aria-level="1" aria-setsize="3"
    aria-posinset="1" tabindex="0">

Gracias a esos atributos, el lector dice algo como «Informática, contraído, nivel 1, 1 de 3». Un nodo sin hijas no lleva aria-expanded, y por eso el CSS li:not([aria-expanded]) le da la sangría de la flecha que no tiene.

Tecla Efecto
Flecha derecha Abre el nodo; si ya está abierto, entra en su primera hija
Flecha izquierda Cierra el nodo; si no se puede cerrar, va al padre
Flecha arriba o abajo Nodo visible anterior o siguiente
Intro, Espacio Selecciona, porque selectionMode es explicit por defecto
Letra Busca por label entre los nodos visibles

⚠️ Importante: Declara [label] en cada nodo. Por defecto la búsqueda usa el textContent del elemento, y el li de un nodo incluye el texto de sus hijas.


11. Menús, barras de herramientas y cuadrículas 🔄 v22.0

🔄 Cambia en Angular 22.0. Menú, barra de herramientas y cuadrícula pasan a estables. Si usas la LTS: en 21 value es obligatorio en ngToolbarWidget y ngMenuItem, y ngMenuBar usa values; añade value="exportar" a cada control de la barra. En la cuadrícula, enableRangeSelection existe solo en 21. Lo demás funciona igual.

Menú y barra de herramientas

Un role="menu" imita un menú de aplicación de escritorio. No lo uses para la navegación de un sitio: una lista de enlaces dentro de <nav> es lo correcto. Tiene sentido para acciones, como las de una hoja de cálculo.

Una barra de herramientas agrupa controles en una sola parada de Tab, y las flechas se mueven por dentro.

// src/app/acciones/acciones.ts
import { Component, signal } from '@angular/core';
import { Menu, MenuContent, MenuItem, MenuTrigger } from '@angular/aria/menu';
import { Toolbar, ToolbarWidget, ToolbarWidgetGroup } from '@angular/aria/toolbar';

@Component({
  selector: 'app-acciones',
  imports: [Menu, MenuContent, MenuItem, MenuTrigger, Toolbar, ToolbarWidget, ToolbarWidgetGroup],
  template: `
    <h1>Acciones sobre la lista</h1>

    <div ngToolbar class="barra" aria-label="Herramientas de la lista">
      <button type="button" ngToolbarWidget (click)="ultima.set('Exportar')">Exportar</button>
      <button type="button" ngToolbarWidget (click)="ultima.set('Imprimir')">Imprimir</button>
      <div ngToolbarWidgetGroup role="group" aria-label="Orden">
        <button type="button" ngToolbarWidget (click)="ultima.set('Por nombre')">Por nombre</button>
        <button type="button" ngToolbarWidget (click)="ultima.set('Por salario')">
          Por salario
        </button>
      </div>
    </div>

    <p>
      <button type="button" ngMenuTrigger [menu]="menu">Más acciones</button>
    </p>
    <div ngMenu #menu="ngMenu" class="menu" (itemSelected)="ultima.set($event)">
      <ng-template ngMenuContent>
        <div ngMenuItem value="Duplicar">Duplicar</div>
        <div ngMenuItem value="Archivar">Archivar</div>
        <div ngMenuItem value="Borrar" [disabled]="true">Borrar</div>
      </ng-template>
    </div>

    <p class="estado">Última acción: {{ ultima() }}</p>
  `,
  styles: `
    .barra {
      display: flex;
      gap: 0.5rem;
    }
    .menu {
      width: 12rem;
      border: 1px solid #3f51b5;
      background: #fff;
    }
    .menu:not([data-visible='true']) {
      display: none;
    }
    .menu [ngMenuItem] {
      padding: 0.4rem 0.75rem;
      cursor: pointer;
    }
    .menu [ngMenuItem]:focus {
      outline: 3px solid #ff7043;
      outline-offset: -3px;
    }
    .menu [ngMenuItem][aria-disabled='true'] {
      color: #5f6b73;
    }
  `,
})
export class Acciones {
  protected readonly ultima = signal<string>('ninguna');
}

Comportamiento comprobado:

Acción Resultado
Foco en Exportar, flecha derecha Foco en Imprimir
Intro o Espacio en un botón de la barra Se dispara su (click)
Tab desde la barra Sale a «Más acciones», sin pasar por los otros botones
Flecha abajo sobre «Más acciones» Abre el menú y enfoca Duplicar
Intro sobre un elemento Lanza itemSelected con su value y devuelve el foco al disparador
Escape Cierra el menú y devuelve el foco al disparador

La directiva marca el menú abierto con data-visible="true", y el CSS lo usa para ocultarlo cuando está cerrado.

Cuadrículas

Una cuadrícula (role="grid") es una tabla interactiva: las flechas recorren las celdas. Si tu tabla solo muestra datos, no la conviertas en cuadrícula: una <table> normal con <th scope> es mejor, porque el lector ya sabe recorrerla.

// src/app/cuadrante/cuadrante.ts
import { Component, signal } from '@angular/core';
import { Grid, GridCell, GridRow } from '@angular/aria/grid';

@Component({
  selector: 'app-cuadrante',
  imports: [Grid, GridCell, GridRow],
  template: `
    <h1 id="titulo-cuadrante">Cuadrante de turnos</h1>

    <table ngGrid class="cuadrante" aria-labelledby="titulo-cuadrante">
      <thead>
        <tr>
          <th scope="col">Empleado</th>
          @for (dia of dias; track dia) {
            <th scope="col">{{ dia }}</th>
          }
        </tr>
      </thead>
      <tbody>
        @for (persona of personas; track persona) {
          <tr ngGridRow>
            <th scope="row">{{ persona }}</th>
            @for (dia of dias; track dia) {
              <td ngGridCell (click)="alternar(persona, dia)" (keydown.enter)="alternar(persona, dia)">
                {{ turnos().has(persona + dia) ? 'Turno' : 'Libre' }}
              </td>
            }
          </tr>
        }
      </tbody>
    </table>
  `,
  styles: `
    .cuadrante {
      border-collapse: collapse;
    }
    .cuadrante th,
    .cuadrante td {
      padding: 0.4rem 0.75rem;
      border: 1px solid #3f51b5;
    }
    .cuadrante td {
      cursor: pointer;
    }
    .cuadrante td:focus-visible {
      outline: 3px solid #ff7043;
      outline-offset: -3px;
    }
  `,
})
export class Cuadrante {
  protected readonly dias = ['Lunes', 'Martes', 'Miércoles'];
  protected readonly personas = ['Ana', 'Luis'];
  protected readonly turnos = signal(new Set<string>());

  protected alternar(persona: string, dia: string) {
    const clave = persona + dia;
    this.turnos.update((actual) => {
      const copia = new Set(actual);
      if (!copia.delete(clave)) {
        copia.add(clave);
      }
      return copia;
    });
  }
}

Tabla de turnos de Ana y Luis de lunes a miércoles, con la celda de Luis el martes marcada como Turno y con el foco

Figura 12. Las flechas recorren las celdas, e Intro alterna el turno.

Partiendo de la primera celda, flecha derecha y flecha abajo llevan el foco a la columna 2 y fila 2. La directiva escribe aria-colindex y aria-rowindex en cada celda. Observa que el Set se copia antes de modificarlo: una señal solo avisa si el valor es otro objeto, como viste en el módulo 05.

Tabla resumen de cambios de 21 a 22

Si debes mantener código en la LTS o subir de versión, esta es la lista, obtenida al comparar las declaraciones de los paquetes 21.2.14 y 22.2.5:

Directiva Angular 21.2 Angular 22.2
Listbox, Tree, MenuBar values value
Combobox Contenedor con filterMode y firstMatch Va en el control, con expanded, value, inlineSuggestion
Campo del combobox input[ngComboboxInput] Desaparece; ngCombobox va en el input
Popup ngComboboxPopupContainer ng-template[ngComboboxPopup] con [combobox] obligatorio
Contenido del popup ngListbox directo ngComboboxWidget con [activeDescendant]
Popup de diálogo dialog[ngComboboxDialog] Desaparece; popupType="dialog"
ToolbarWidget, MenuItem value obligatorio ToolbarWidget sin value; en MenuItem es opcional
Toolbar values Desaparece
Grid enableRangeSelection Desaparece
Harnesses de prueba No existen @angular/aria/*/testing

Tabs y Accordion no cambian. La API actual ya está en la 22.0.0.


12. Gestión del foco

El foco es tu responsabilidad cada vez que el contenido cambia. Las reglas son simples: el foco nunca debe quedar en un elemento que ha desaparecido, y el usuario debe saber siempre dónde está.

Foco visible

Nunca quites el contorno sin dar una alternativa. Este CSS es uno de los errores de accesibilidad más comunes:

/* Mal: deja a los usuarios de teclado a ciegas */
button:focus {
  outline: none;
}

/* Bien: se ve con teclado y no molesta con el ratón */
button:focus-visible {
  outline: 3px solid #ff7043;
  outline-offset: 2px;
}

:focus-visible hace que el navegador muestre el contorno cuando es útil, en general al usar el teclado, y no tras un clic con el ratón.

Enlace de salto y cambios de ruta

Quien navega con teclado no quiere recorrer todo el menú en cada página. Por eso se pone al principio un enlace que salta al contenido, oculto hasta que recibe el foco.

Además, hay un problema propio de las aplicaciones de una sola página. Al navegar entre páginas normales, el navegador carga una página nueva y el lector la anuncia. Con el router de Angular cambia parte del DOM y no ocurre nada audible, y el foco se queda donde estaba o se pierde si ese elemento desaparece. La solución es llevar el foco al contenido al terminar cada navegación:

// src/app/app.ts
import { Component, ElementRef, inject, viewChild } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router, RouterOutlet } from '@angular/router';
import { filter } from 'rxjs';

@Component({
  selector: 'app-root',
  imports: [RouterOutlet],
  template: `
    <a class="saltar" href="#contenido" (click)="saltar($event)">Saltar al contenido</a>
    <main #principal id="contenido" tabindex="-1">
      <router-outlet />
    </main>
  `,
  styles: `
    :host {
      display: block;
      padding: 1rem;
      font-family: 'Segoe UI', Arial, sans-serif;
      color: #263238;
    }
    .saltar {
      position: absolute;
      left: -999px;
    }
    .saltar:focus {
      left: 1rem;
      top: 0.5rem;
      padding: 0.5rem;
      background: #fff;
    }
    main:focus {
      outline: none;
    }
  `,
})
export class App {
  private readonly principal = viewChild.required<ElementRef<HTMLElement>>('principal');

  constructor() {
    let primera = true;
    inject(Router)
      .events.pipe(
        filter((e) => e instanceof NavigationEnd),
        takeUntilDestroyed(),
      )
      .subscribe(() => {
        if (primera) {
          primera = false;
          return;
        }
        this.principal().nativeElement.focus();
      });
  }

  protected saltar(evento: Event) {
    evento.preventDefault();
    this.principal().nativeElement.focus();
  }
}

Página de alta con un enlace Saltar al contenido visible arriba porque tiene el foco

Figura 13. Es lo primero que encuentra el teclado al pulsar Tab. El ratón nunca lo ve.

Puntos clave:

  • tabindex="-1" permite enfocar main por código, sin añadirlo al recorrido de Tab.
  • La primera navegación se ignora, para no robar el foco al cargar la página.
  • preventDefault es necesario porque, con un <base href>, #contenido apuntaría a la raíz de la aplicación.
  • takeUntilDestroyed cancela la suscripción, como viste en el módulo 15.

El resultado medido: Tab enfoca el enlace, Intro lleva el foco a main, y tras cambiar de ruta el foco vuelve a main y el título de la pestaña cambia.

Diálogos y elementos que desaparecen

Este componente reúne tres situaciones: abrir un diálogo, cerrarlo y borrar el elemento que tenía el foco. Usa cdkTrapFocus, que encierra el foco dentro del diálogo.

// src/app/confirmar-borrado/confirmar-borrado.ts
import { A11yModule, LiveAnnouncer } from '@angular/cdk/a11y';
import {
  Component,
  ElementRef,
  Injector,
  afterNextRender,
  inject,
  signal,
  viewChild,
  viewChildren,
} from '@angular/core';

interface Persona {
  id: number;
  nombre: string;
}

@Component({
  selector: 'app-confirmar-borrado',
  imports: [A11yModule],
  template: `
    <h1 #titulo tabindex="-1">Equipo</h1>

    <ul class="equipo">
      @for (p of personas(); track p.id) {
        <li>
          {{ p.nombre }}
          <button
            #botonBorrar
            type="button"
            [attr.aria-label]="'Borrar a ' + p.nombre"
            (click)="pedirConfirmacion(p, $any($event.currentTarget))"
          >
            Borrar
          </button>
        </li>
      }
    </ul>

    @if (aBorrar(); as p) {
      <div class="fondo">
        <div
          role="alertdialog"
          aria-modal="true"
          aria-labelledby="titulo-dialogo"
          class="dialogo"
          cdkTrapFocus
          [cdkTrapFocusAutoCapture]="true"
          (keydown.escape)="cancelar()"
        >
          <h2 id="titulo-dialogo">¿Borrar a {{ p.nombre }}?</h2>
          <button type="button" cdkFocusInitial (click)="cancelar()">Cancelar</button>
          <button type="button" (click)="borrar(p)">Borrar</button>
        </div>
      </div>
    }
  `,
  styles: `
    .equipo button:focus-visible,
    .dialogo button:focus-visible {
      outline: 3px solid #ff7043;
      outline-offset: 2px;
    }
    .fondo {
      position: fixed;
      inset: 0;
      display: grid;
      place-items: center;
      background: rgb(0 0 0 / 40%);
    }
    .dialogo {
      padding: 1.5rem;
      background: #fff;
      border-radius: 8px;
    }
    h1:focus {
      outline: none;
    }
  `,
})
export class ConfirmarBorrado {
  private readonly anunciador = inject(LiveAnnouncer);
  private readonly inyector = inject(Injector);

  private readonly titulo = viewChild.required<ElementRef<HTMLElement>>('titulo');
  private readonly botones = viewChildren<ElementRef<HTMLElement>>('botonBorrar');

  private disparador?: HTMLElement;

  protected readonly personas = signal<Persona[]>([
    { id: 100, nombre: 'Ana García' },
    { id: 101, nombre: 'Luis Martínez' },
    { id: 102, nombre: 'Marta Ruiz' },
  ]);
  protected readonly aBorrar = signal<Persona | undefined>(undefined);

  protected pedirConfirmacion(persona: Persona, boton: HTMLElement) {
    this.disparador = boton;
    this.aBorrar.set(persona);
  }

  protected cancelar() {
    this.aBorrar.set(undefined);
    this.disparador?.focus();
  }

  protected borrar(persona: Persona) {
    const indice = this.personas().findIndex((p) => p.id === persona.id);
    this.personas.update((lista) => lista.filter((p) => p.id !== persona.id));
    this.aBorrar.set(undefined);
    this.anunciador.announce(`${persona.nombre} borrada del equipo`);

    // El botón pulsado ya no existe: hay que decidir a dónde va el foco.
    afterNextRender(
      () => {
        const restantes = this.botones();
        const siguiente = restantes[Math.min(indice, restantes.length - 1)];
        (siguiente ?? this.titulo()).nativeElement.focus();
      },
      { injector: this.inyector },
    );
  }
}

Diálogo de confirmación centrado sobre un fondo oscurecido, con el foco en el botón Cancelar

Figura 14. El foco entra en el diálogo y empieza en la opción menos peligrosa.

Sigue el recorrido del foco, tal como se comporta en la prueba:

Acción Dónde queda el foco Motivo
Intro en «Borrar a Luis Martínez» Cancelar cdkTrapFocusAutoCapture y cdkFocusInitial
Tab Borrar Dentro del diálogo
Tab otra vez Cancelar La trampa da la vuelta
Escape El botón «Borrar a Luis Martínez» cancelar devuelve el foco al disparador
Confirmar el borrado «Borrar a Marta Ruiz» El elemento siguiente ocupa su lugar

Si no quedase ningún botón, siguiente sería undefined y el código llevaría el foco al h1, que por eso tiene tabindex="-1".

Los detalles importan:

  • aria-label da a cada botón un nombre propio. Sin él, el lector anunciaría tres veces «Borrar», sin decir a quién.
  • afterNextRender espera a que el DOM esté actualizado, como viste en el módulo 03. Si llamases a focus justo después de update, los botones aún serían los anteriores.
  • El foco empieza en Cancelar: en acciones destructivas, un Intro distraído no debe borrar.
  • role="alertdialog" indica un diálogo que exige una respuesta. Para uno corriente se usa role="dialog".

💡 Consejo: Este ejemplo muestra cómo funciona la gestión del foco. En un proyecto real, usa antes <dialog> con showModal(), que hace inerte el resto de la página, o MatDialog y el Dialog del CDK, que viste en el módulo 20. cdkTrapFocus solo controla el foco: el resto sigue accesible para el lector, que debe confiar en aria-modal.

Saber de dónde viene el foco

A veces necesitas distinguir el ratón del teclado. cdkMonitorElementFocus añade clases según el origen, y cdkFocusChange lo emite:

<button
  type="button"
  cdkMonitorElementFocus
  class="monitorizado"
  (cdkFocusChange)="origen.set($event)"
>
  Pruébame con ratón y con teclado
</button>
protected readonly origen = signal<FocusOrigin>(null);
Tras un clic:  Origen: mouse     clases: cdk-focused cdk-mouse-focused
Tras un Tab:   Origen: keyboard  clases: cdk-focused cdk-keyboard-focused

El servicio FocusMonitor ofrece lo mismo desde el código, con monitor(), stopMonitoring() y focusVia().


13. Anuncios con LiveAnnouncer y cdkAriaLive

Un lector de pantalla lee lo que tiene el foco. Si aparece «Guardado» en una esquina, quien no ve la pantalla no se entera. El criterio 4.1.3 exige que esos mensajes de estado lleguen sin mover el foco, y el mecanismo es una región viva: un elemento con aria-live, cuyos cambios anuncia el lector.

Las regiones vivas son delicadas: el elemento debe existir antes de que cambie su texto. Si lo creas con el mensaje dentro, el lector no suele decir nada. LiveAnnouncer resuelve esto, porque mantiene un elemento oculto preparado en la página.

// src/app/avisos/avisos.ts
import { A11yModule, FocusOrigin, LiveAnnouncer } from '@angular/cdk/a11y';
import { Component, inject, signal } from '@angular/core';

@Component({
  selector: 'app-avisos',
  imports: [A11yModule],
  template: `
    <h1>Avisos y foco</h1>

    <p>
      <button type="button" (click)="guardar()">Guardar</button>
      <button type="button" (click)="fallar()">Simular error</button>
      <button type="button" (click)="contar()">Añadir al carrito</button>
    </p>

    <p cdkAriaLive="polite" class="carrito">{{ unidades() }} unidades en el carrito</p>

    <button
      type="button"
      cdkMonitorElementFocus
      class="monitorizado"
      (cdkFocusChange)="origen.set($event)"
    >
      Pruébame con ratón y con teclado
    </button>

    <p class="estado">Origen del foco: {{ origen() ?? 'sin foco' }}</p>
  `,
  styles: `
    .monitorizado.cdk-keyboard-focused {
      outline: 3px solid #ff7043;
      outline-offset: 2px;
    }
    .monitorizado.cdk-mouse-focused {
      outline: none;
    }
  `,
})
export class Avisos {
  private readonly anunciador = inject(LiveAnnouncer);

  protected readonly unidades = signal(0);
  protected readonly origen = signal<FocusOrigin>(null);

  protected guardar() {
    this.anunciador.announce('Empleado guardado');
  }

  protected fallar() {
    this.anunciador.announce('No se pudo guardar el empleado', 'assertive');
  }

  protected contar() {
    this.unidades.update((n) => n + 1);
  }
}

El contenido real de la región viva tras pulsar cada botón es este:

Guardar             -> polite:    Empleado guardado
Simular error       -> assertive: No se pudo guardar el empleado
Añadir al carrito   -> polite:    1 unidades en el carrito
Prioridad Comportamiento Uso
polite (por defecto) Espera a que el lector termine de hablar Guardado, resultados, cambios de contador
assertive Interrumpe Errores graves, pérdida de sesión. Con mucha moderación

Otras opciones: announce admite una duración en milisegundos como último argumento y devuelve una promesa, y clear() vacía la región. cdkAriaLive mira el texto de un elemento y lo anuncia cuando cambia, sin que llames a nada.

⚠️ Importante: Carga los estilos del CDK. Sin ellos, el elemento del anunciador se ve en la página: al probar el buscador sin esta línea, medía 744 píxeles de ancho y el texto asomaba detrás del contenido. Con ella mide 1 píxel y queda oculto.

/* src/styles.css */
@import '@angular/cdk/a11y-prebuilt.css';

Anunciar en el buscador

Volvamos al effect del combobox:

effect(() => {
  if (this.resultado.isLoading() || !this.abierto()) {
    return;
  }
  const total = this.empleados().length;
  untracked(() =>
    this.anunciador.announce(
      total === 0 ? 'Sin resultados' : `${total} resultados. Usa las flechas para recorrerlos.`,
    ),
  );
});

Se ejecuta cuando la carga termina, y calla mientras hay una petición en curso o la lista está cerrada, para no decir nada a medias. untracked evita que las señales que pudiera leer el anuncio se conviertan en dependencias, como viste en el módulo 05.

💡 Consejo: No lo anuncies todo. Si cada pulsación genera un mensaje, el usuario oirá una ráfaga de avisos. Anuncia el resultado de una acción, no cada paso.


14. Formularios, contraste y movimiento

Formularios accesibles

Las reglas de la sección 3 valen para cualquier formulario, ya sea por plantilla, reactivo o con Signal Forms (módulos 16, 17 y 18):

  • Cada campo tiene su <label for> visible. Un placeholder no es una etiqueta.
  • autocomplete="email" y similares ayudan a quienes tienen dificultades para escribir.
  • Los errores se escriben con texto, no solo en rojo.
  • aria-invalid marca el campo, y aria-describedby lo une al mensaje de error.
  • novalidate evita los globos nativos, que no siempre encajan con tu diseño, pero entonces la validación es tu responsabilidad.
  • Al enviar con errores, mueve el foco al primer campo incorrecto.
  • Agrupa radios y casillas relacionadas con <fieldset> y <legend>.

Este es el campo real tras enviar sin correo:

<input id="correo" type="email" autocomplete="email" required
       aria-invalid="true" aria-describedby="correo-error">

Contraste

El criterio 1.4.3 pide un contraste de al menos 4,5:1 para el texto normal, y de 3:1 para el texto grande. Los controles deben alcanzar 3:1 frente a lo que tienen al lado, según el 1.4.11.

En la figura 2 fallan tres elementos: el texto blanco sobre azul claro, el error en rojo puro y el enlace gris. En la figura 3 se resuelven con #283593 y #b71c1c, sin renunciar al color. Las pestañas desactivadas de este módulo usan #5f6b73 en lugar del típico gris claro, para que sigan siendo legibles.

El panel Styles de Chrome DevTools muestra la relación de contraste al pulsar sobre la muestra de un color.

Movimiento

Hay personas a las que las animaciones les provocan mareos. El sistema operativo tiene una opción para reducirlas, y CSS puede respetarla:

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

Y una regla sencilla para el zoom: usa rem para los tamaños de letra y evita alturas fijas en los contenedores con texto.


15. Auditar con axe

axe es un motor de reglas de accesibilidad, de código abierto, que está detrás de muchas herramientas. Se usa en el navegador con una extensión, y dentro de tus pruebas.

En Playwright, con @axe-core/playwright

Es la opción más fiable, porque corre en un navegador real, que calcula colores y estilos.

npm install -D @playwright/test @axe-core/playwright
// e2e/accesibilidad.spec.ts
import { expect, test } from '@playwright/test';
import { AxeBuilder } from '@axe-core/playwright';

test('el alta de empleado no tiene infracciones', async ({ page }) => {
  await page.goto('/bueno');
  await page.getByRole('button', { name: 'Guardar' }).click();

  const resultado = await new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa', 'wcag22aa', 'best-practice'])
    .analyze();

  expect(resultado.violations).toEqual([]);
});

Veremos Playwright con detalle en el módulo 26. Pasar axe 4.14 por las pantallas de este módulo, con esas etiquetas, da estos resultados reales:

malo: 3 infracciones, 22 reglas pasadas
  color-contrast [serious] 3 nodo(s)
  image-alt [critical] 1 nodo(s)
  page-has-heading-one [moderate] 1 nodo(s)
bueno:      0 infracciones, 34 reglas pasadas
pestañas:   0 infracciones
acordeón:   0 infracciones
lista:      0 infracciones
buscador abierto: 0 infracciones
select:     0 infracciones
árbol:      0 infracciones
acciones:   0 infracciones
cuadrante:  0 infracciones

Analiza la pantalla en cada estado importante, con el popup abierto y el error visible. Si analizas solo la inicial, axe no llega a ver lo demás.

Lo que axe no ve

Lee otra vez el resultado de malo. Falta lo más grave: axe no ha detectado el div que hace de botón, y ese botón no se puede usar con teclado. Pulsando Tab, el foco salta del campo al enlace, y el formulario no se puede enviar. Tampoco protesta por el placeholder como etiqueta, ni por el error sin asociar al campo.

No es un fallo de axe. Una regla automática puede comprobar si un <img> tiene alt, pero no si un div con un manejador de eventos que no aparece en el HTML debería ser un botón, ni si el texto alternativo describe bien la imagen.

⚠️ Importante: Cero infracciones en axe no significa que la pantalla sea accesible. Significa que has pasado la parte automática. La comprobación manual no se puede saltar.

Comprobación manual en cinco minutos

  1. Desconecta el ratón. ¿Llegas a todo con Tab? ¿Se ve siempre el foco? ¿Puedes salir de cada componente?
  2. Revisa el orden. ¿Sigue el foco un orden lógico?
  3. Amplía al 200 %. ¿Se corta algún contenido?
  4. Enciende un lector de pantalla. NVDA es gratuito en Windows, y VoiceOver viene con macOS. ¿Se entiende cada control sin verlo?
  5. Recorre los encabezados. ¿Tienen sentido como índice de la página?

En Vitest, con axe-core

En pruebas unitarias también puedes usar axe. Como jsdom no pinta nada, no puede calcular colores, y hay que desactivar esa regla.

npm install -D axe-core
// src/app/accesibilidad.spec.ts (parte 1)
import { TestBed } from '@angular/core/testing';
import axe from 'axe-core';
import { describe, expect, it } from 'vitest';
import { Bueno } from './malo-bueno/bueno';
import { Malo } from './malo-bueno/malo';

async function infracciones(elemento: HTMLElement) {
  const resultado = await axe.run(elemento, {
    rules: { 'color-contrast': { enabled: false } },
  });
  return resultado.violations.map((v) => v.id);
}

describe('accesibilidad', () => {
  it('axe no encuentra fallos en el formulario correcto', async () => {
    const fixture = TestBed.createComponent(Bueno);
    await fixture.whenStable();

    expect(await infracciones(fixture.nativeElement)).toEqual([]);
  });

  it('axe detecta la imagen sin alt del formulario incorrecto', async () => {
    const fixture = TestBed.createComponent(Malo);
    await fixture.whenStable();

    expect(await infracciones(fixture.nativeElement)).toContain('image-alt');
  });
});

Harnesses de Angular Aria 🆕 v22.0

🆕 Desde Angular 22.0. No existe en la LTS (Angular 21). Si usas la LTS: localiza los elementos por su rol y pulsa sobre ellos en la prueba, por ejemplo con fixture.nativeElement.querySelectorAll('[role=tab]').

Cada patrón incluye un harness, una clase de pruebas que opera el componente como lo haría un usuario y te aísla de los detalles del DOM. Viven en subrutas /testing:

// src/app/accesibilidad.spec.ts (parte 2)
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { TabsHarness } from '@angular/aria/tabs/testing';
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Tab, TabContent, TabList, TabPanel, Tabs } from '@angular/aria/tabs';
import { expect, it } from 'vitest';

@Component({
  imports: [Tab, TabContent, TabList, TabPanel, Tabs],
  template: `
    <div ngTabs>
      <ul ngTabList selectedTab="uno" aria-label="Prueba">
        <li ngTab value="uno">Uno</li>
        <li ngTab value="dos">Dos</li>
      </ul>
      <div ngTabPanel value="uno"><ng-template ngTabContent>Panel uno</ng-template></div>
      <div ngTabPanel value="dos"><ng-template ngTabContent>Panel dos</ng-template></div>
    </div>
  `,
})
class PestanasDePrueba {}

it('selecciona una pestaña con el harness', async () => {
  const fixture = TestBed.createComponent(PestanasDePrueba);
  const loader = TestbedHarnessEnvironment.loader(fixture);
  const pestanas = await loader.getHarness(TabsHarness);

  await pestanas.selectTab({ title: 'Dos' });

  const activa = await pestanas.getSelectedTab();
  expect(await activa?.getTitle()).toBe('Dos');
  expect(fixture.nativeElement.textContent).toContain('Panel dos');
});

Las tres pruebas pasan:

 Test Files  1 passed (1)
      Tests  3 passed (3)

💡 Consejo: Sin Zone.js, el DOM se actualiza después de tu evento, no durante. Si una prueba pulsa una tecla y lee el DOM en la línea siguiente, puede leer el estado anterior. Usa await fixture.whenStable(), un harness o las aserciones de Playwright, que reintentan solas. Lo explica el módulo 09.


16. Errores frecuentes

Síntoma Causa Solución
El panel oculto sigue visible inert no oculta nada [ngTabPanel][inert] { display: none; }
Se pierde lo escrito en una pestaña El contenido se destruye al ocultarse [preserveContent]="true"
TS2322 en preserveContent Falta enlazar un booleano Usa corchetes
El desplegable no se cierra al elegir El popup no se cierra solo Pon expanded a false en click y keydown.enter
Texto extraño al final de la página Faltan los estilos del CDK @import '@angular/cdk/a11y-prebuilt.css'
El menú cerrado deja un recuadro Falta ocultarlo .menu:not([data-visible='true']) { display: none; }
Un elemento desactivado recibe el foco softDisabled es true por defecto Es lo deseable; si no, [softDisabled]="false"
«Abrir todo» no hace nada multiExpandable es false expandAll solo actúa si admite varios
La búsqueda por letras no acierta Toma el texto de todo el nodo Declara [label]
El lector dice el estado dos veces Icono de estado sin ocultar aria-hidden="true" en el icono
El lector dice «Borrar» tres veces Mismo nombre en cada fila [attr.aria-label] con el dato
El foco desaparece tras borrar El elemento enfocado ya no existe Muévelo en afterNextRender
value no existe en ngListbox Código de 22 en un proyecto 21 values en 21

17. Ejercicios

Ejercicio 1: Un botón de icono

Este botón elimina un pedido, pero no funciona con teclado ni tiene nombre. Corrígelo:

<span class="icono" (click)="eliminar()">
  <svg width="16" height="16" viewBox="0 0 16 16">
    <path d="M3 3 L13 13 M13 3 L3 13" stroke="currentColor" stroke-width="2" />
  </svg>
</span>
Ver solución
import { Component, input, signal } from '@angular/core';

@Component({
  selector: 'app-eliminar-pedido',
  template: `
    <button type="button" class="icono" [attr.aria-label]="'Eliminar pedido ' + pedidoId()" (click)="eliminar()">
      <svg aria-hidden="true" focusable="false" width="16" height="16" viewBox="0 0 16 16">
        <path d="M3 3 L13 13 M13 3 L3 13" stroke="currentColor" stroke-width="2" />
      </svg>
    </button>
    <p class="estado">Eliminaciones: {{ veces() }}</p>
  `,
  styles: `
    .icono {
      display: inline-grid;
      place-items: center;
      width: 44px;
      height: 44px;
      color: #b71c1c;
      background: #fff;
      border: 1px solid #b71c1c;
      border-radius: 6px;
      cursor: pointer;
    }
    .icono:focus-visible {
      outline: 3px solid #ff7043;
      outline-offset: 2px;
    }
  `,
})
export class EliminarPedido {
  readonly pedidoId = input(5001);
  protected readonly veces = signal(0);

  protected eliminar() {
    this.veces.update((n) => n + 1);
  }
}

Botón cuadrado con una equis roja, con el foco, y debajo el texto Eliminaciones: 2

Figura 15. El botón responde a Espacio y a Intro sin programar ninguna tecla.

<button> da foco y teclado sin esfuerzo; en la prueba, Espacio e Intro cuentan dos eliminaciones. aria-label da un nombre con el dato concreto, y aria-hidden oculta el dibujo al lector. Si el icono fuese un <div> con role="button", tendrías que programar tabindex, Intro y Espacio a mano.

Ejercicio 2: Notas que sobreviven

Crea unas pestañas Notas e Historial. Notas tiene un textarea, cuyo contenido debe conservarse al cambiar de pestaña. Cambiar de pestaña con las flechas no debe seleccionar nada hasta pulsar Intro o Espacio.

Ver solución
import { Component } from '@angular/core';
import { Tab, TabContent, TabList, TabPanel, Tabs } from '@angular/aria/tabs';

@Component({
  selector: 'app-notas-empleado',
  imports: [Tab, TabContent, TabList, TabPanel, Tabs],
  template: `
    <div ngTabs class="pestanas">
      <ul ngTabList selectionMode="explicit" aria-label="Notas">
        <li ngTab value="notas">Notas</li>
        <li ngTab value="historial">Historial</li>
      </ul>
      <div ngTabPanel value="notas" [preserveContent]="true">
        <ng-template ngTabContent>
          <label for="nota">Nota</label>
          <textarea id="nota"></textarea>
        </ng-template>
      </div>
      <div ngTabPanel value="historial">
        <ng-template ngTabContent><p>Sin cambios recientes.</p></ng-template>
      </div>
    </div>
  `,
  styles: `
    [ngTabList] {
      display: flex;
      gap: 1rem;
      padding: 0;
      list-style: none;
    }
    [ngTab][aria-selected='true'] {
      font-weight: bold;
      text-decoration: underline;
    }
    [ngTabPanel][inert] {
      display: none;
    }
  `,
})
export class NotasEmpleado {}

Comprobado: la flecha mueve el foco sin seleccionar; Espacio selecciona Historial; el textarea sigue en el DOM mientras está oculto, y al volver conserva «Revisar contrato». Aquí [ngTabPanel][inert] es imprescindible, porque el panel oculto ya no está vacío.

Ejercicio 3: Departamentos en acordeón

Muestra los departamentos de la API en un acordeón con un solo panel abierto a la vez. Cada panel carga sus empleados con /departamentos/:id/empleados, y la petición no debe hacerse hasta abrirlo.

Ver solución
import { httpResource } from '@angular/common/http';
import { Component, computed, input } from '@angular/core';
import {
  AccordionContent,
  AccordionGroup,
  AccordionPanel,
  AccordionTrigger,
} from '@angular/aria/accordion';
import { API } from '../api';
import { Departamento, Empleado, Pagina } from '../modelos';

@Component({
  selector: 'app-empleados-departamento',
  template: `
    <ul>
      @for (e of empleados(); track e.empId) {
        <li>{{ e.nombre }} {{ e.apellidos }}</li>
      } @empty {
        <li>Cargando...</li>
      }
    </ul>
  `,
})
export class EmpleadosDepartamento {
  readonly deptId = input.required<number>();

  private readonly pagina = httpResource<Pagina<Empleado>>(
    () => `${API}/departamentos/${this.deptId()}/empleados`,
  );

  protected readonly empleados = computed(() => this.pagina.value()?.contenido ?? []);
}

@Component({
  selector: 'app-acordeon-departamentos',
  imports: [AccordionContent, AccordionGroup, AccordionPanel, AccordionTrigger, EmpleadosDepartamento],
  template: `
    <h1>Departamentos</h1>
    <div ngAccordionGroup [multiExpandable]="false">
      @for (d of departamentos(); track d.deptId) {
        <h2>
          <button ngAccordionTrigger [panel]="panel">{{ d.nombre }}</button>
        </h2>
        <div ngAccordionPanel #panel="ngAccordionPanel">
          <ng-template ngAccordionContent>
            <app-empleados-departamento [deptId]="d.deptId" />
          </ng-template>
        </div>
      }
    </div>
  `,
  styles: `
    h2 {
      margin: 0.25rem 0;
      font-size: 1rem;
    }
    [ngAccordionPanel][inert] {
      display: none;
    }
  `,
})
export class AcordeonDepartamentos {
  private readonly pagina = httpResource<Pagina<Departamento>>(() => `${API}/departamentos`);

  protected readonly departamentos = computed(() => this.pagina.value()?.contenido ?? []);
}

La clave es poner el httpResource en un componente hijo dentro de ngAccordionContent: el hijo no se crea hasta abrir el panel, así que tampoco hay petición. Al abrir VENTAS se muestran seis empleados, y al abrir otro panel el anterior se cierra: solo queda un aria-expanded="true".

Fíjate en que la referencia #panel funciona dentro de @for, porque cada vuelta tiene su propio ámbito.

Ejercicio 4: Filtro horizontal

Crea un filtro de estado de pedido con PENDIENTE, ENVIADO y ENTREGADO, que se elija con las flechas izquierda y derecha, con selección simple que siga al foco.

Ver solución
import { Component, signal } from '@angular/core';
import { Listbox, Option } from '@angular/aria/listbox';

@Component({
  selector: 'app-filtro-estado',
  imports: [Listbox, Option],
  template: `
    <h1 id="titulo-filtro">Estado del pedido</h1>
    <ul
      ngListbox
      class="filtro"
      orientation="horizontal"
      aria-labelledby="titulo-filtro"
      [(value)]="estado"
    >
      @for (e of estados; track e) {
        <li ngOption [value]="e" [label]="e">{{ e }}</li>
      }
    </ul>
    <p class="estado">Filtro: {{ estado().at(0) ?? 'todos' }}</p>
  `,
  styles: `
    .filtro {
      display: flex;
      gap: 0.5rem;
      padding: 0;
      list-style: none;
    }
    .filtro [ngOption] {
      padding: 0.25rem 0.75rem;
      border: 1px solid #3f51b5;
      border-radius: 999px;
      cursor: pointer;
    }
    .filtro [ngOption][aria-selected='true'] {
      background: #283593;
      color: #fff;
    }
    .filtro [ngOption]:focus-visible {
      outline: 3px solid #ff7043;
      outline-offset: 2px;
    }
  `,
})
export class FiltroEstado {
  protected readonly estados = ['PENDIENTE', 'ENVIADO', 'ENTREGADO'];
  protected readonly estado = signal<string[]>([]);
}

Título Estado del pedido y tres opciones redondeadas, con ENVIADO seleccionada y con el foco

Figura 16. El mismo listbox, en horizontal y con otro CSS.

selectionMode no aparece porque su valor por defecto ya es follow. orientation="horizontal" cambia las flechas que usa la directiva y escribe aria-orientation="horizontal". Usa .at(0) y no [0]: su tipo incluye undefined, y ?? queda justificado para el compilador de plantillas.

Ejercicio 5: Buscador de oficios con filtro local

Crea un combobox con una lista fija de oficios. Filtra con un computed, anuncia el número de resultados al escribir, y muestra un texto cuando no haya ninguno.

Ver solución
import { Component, computed, inject, signal, viewChild } from '@angular/core';
import { LiveAnnouncer } from '@angular/cdk/a11y';
import { Combobox, ComboboxPopup, ComboboxWidget } from '@angular/aria/combobox';
import { Listbox, Option } from '@angular/aria/listbox';

@Component({
  selector: 'app-buscador-oficios',
  imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option],
  template: `
    <h1>Oficio</h1>
    <div class="caja">
      <label for="oficio">Oficio</label>
      <input
        id="oficio"
        ngCombobox
        #combobox="ngCombobox"
        [(value)]="texto"
        [(expanded)]="abierto"
        (input)="avisar()"
      />
      <ng-template ngComboboxPopup [combobox]="combobox">
        <ul
          ngListbox
          ngComboboxWidget
          #lista="ngListbox"
          aria-label="Oficios"
          focusMode="activedescendant"
          selectionMode="explicit"
          [activeDescendant]="lista.activeDescendant()"
          [(value)]="seleccion"
          (click)="confirmar()"
          (keydown.enter)="confirmar()"
        >
          @for (o of filtrados(); track o) {
            <li ngOption [value]="o" [label]="o">{{ o }}</li>
          }
        </ul>
      </ng-template>
    </div>
    @if (filtrados().length === 0) {
      <p>No hay oficios que coincidan.</p>
    }
  `,
  styles: `
    .caja {
      position: relative;
      width: 18rem;
    }
    ul {
      position: absolute;
      z-index: 1;
      width: 100%;
      margin: 0;
      padding: 0;
      list-style: none;
      background: #fff;
      border: 1px solid #3f51b5;
    }
    ul:not(:has(li)) {
      display: none;
    }
    li[data-active='true'] {
      outline: 2px solid #ff7043;
      outline-offset: -2px;
    }
  `,
})
export class BuscadorOficios {
  private readonly anunciador = inject(LiveAnnouncer);
  private readonly comboboxRef = viewChild.required(Combobox);
  private readonly oficios = ['ANALISTA', 'COMERCIAL', 'CONTABLE', 'DIRECTOR GENERAL', 'PROGRAMADOR'];

  protected readonly texto = signal('');
  protected readonly abierto = signal(false);
  protected readonly seleccion = signal<string[]>([]);
  protected readonly filtrados = computed(() =>
    this.oficios.filter((o) => o.includes(this.texto().toUpperCase())),
  );

  protected avisar() {
    const total = this.filtrados().length;
    this.anunciador.announce(total === 0 ? 'Sin resultados' : `${total} oficios`);
  }

  protected confirmar() {
    const oficio = this.seleccion()[0];
    if (oficio) {
      this.texto.set(oficio);
      this.abierto.set(false);
      this.comboboxRef().element.focus();
    }
  }
}

Al escribir co aparecen COMERCIAL y CONTABLE, y se anuncia «2 oficios». Flecha abajo e Intro dejan CONTABLE en el campo. Con zz se ve el párrafo de aviso y se anuncia «Sin resultados».

Aquí avisar responde a (input) en lugar de usar un effect: como el filtro es local y síncrono, ya conoces el total. El mensaje visible y el anuncio cumplen funciones distintas: uno es para quien ve la pantalla, el otro para quien no.

Con :has(li) se oculta el recuadro cuando no hay opciones.

Ejercicio 6: Menú lateral con árbol de navegación

Crea un menú lateral con un árbol, con todo desplegado. La sección actual debe tener aria-current="page", y debe cambiar al moverse.

Ver solución
import { NgTemplateOutlet } from '@angular/common';
import { Component, signal } from '@angular/core';
import { Tree, TreeItem, TreeItemGroup } from '@angular/aria/tree';

interface Enlace {
  ruta: string;
  texto: string;
  hijos?: Enlace[];
}

@Component({
  selector: 'app-menu-lateral',
  imports: [NgTemplateOutlet, Tree, TreeItem, TreeItemGroup],
  template: `
    <nav aria-label="Secciones">
      <ul
        ngTree
        #arbol="ngTree"
        nav
        currentType="page"
        selectionMode="follow"
        [(value)]="actual"
      >
        <ng-template
          [ngTemplateOutlet]="nodos"
          [ngTemplateOutletContext]="{ lista: enlaces, padre: arbol }"
        />
      </ul>
    </nav>

    <ng-template #nodos let-lista="lista" let-padre="padre">
      @for (e of lista; track e.ruta) {
        <li ngTreeItem #item="ngTreeItem" [parent]="padre" [value]="e.ruta" [label]="e.texto" [expanded]="true">
          <span>{{ e.texto }}</span>
          @if (e.hijos) {
            <ul role="group">
              <ng-template ngTreeItemGroup [ownedBy]="item" #grupo="ngTreeItemGroup">
                <ng-template
                  [ngTemplateOutlet]="nodos"
                  [ngTemplateOutletContext]="{ lista: e.hijos, padre: grupo }"
                />
              </ng-template>
            </ul>
          }
        </li>
      }
    </ng-template>

    <p class="estado">Sección: {{ actual()[0] }}</p>
  `,
  styles: `
    ul {
      list-style: none;
      padding-left: 1rem;
    }
    li[aria-current='page'] > span {
      font-weight: bold;
      text-decoration: underline;
    }
    li:focus-visible > span {
      outline: 3px solid #ff7043;
    }
  `,
})
export class MenuLateral {
  protected readonly enlaces: Enlace[] = [
    { ruta: '/panel', texto: 'Panel' },
    {
      ruta: '/personal',
      texto: 'Personal',
      hijos: [
        { ruta: '/empleados', texto: 'Empleados' },
        { ruta: '/departamentos', texto: 'Departamentos' },
      ],
    },
  ];
  protected readonly actual = signal<string[]>(['/empleados']);
}

Con nav, la directiva sustituye aria-selected por aria-current="page" en el elemento actual. Al cargar, ese atributo está en Empleados; al recorrer el árbol con las flechas, pasa a Departamentos y solo hay uno. En una aplicación real, conectarías actual con el router.

Ejercicio 7: Pruebas de accesibilidad

Escribe una prueba que confirme que el formulario Bueno no tiene infracciones, y otra que confirme que axe detecta el problema de Malo. Explica por qué no detecta el div como botón.

Ver solución

La solución es el código de src/app/accesibilidad.spec.ts de la sección 15, y sus pruebas pasan. La regla color-contrast se desactiva porque jsdom no calcula el aspecto final de la página. La regla image-alt funciona porque solo necesita el HTML.

axe no detecta el div porque el manejador (click) lo registra Angular con addEventListener, y no deja ninguna huella en el HTML. Solo ve un div con texto, algo perfectamente válido. Por eso conviene buscar los controles por su rol, como hace Playwright:

await expect(page.getByRole('button', { name: 'Guardar' })).toHaveCount(1);

Esa línea falla en Malo, porque ningún elemento tiene el rol button, y pasa en Bueno.

Ejercicio 8 (propuesto): Menú contextual de fila

Añade a cada fila de una tabla de empleados un botón «Acciones» que abra un menú con Editar y Borrar. Da a cada botón un nombre con el empleado, y devuelve el foco a un sitio lógico tras borrar.

Ejercicio 9 (propuesto): Cuadrícula con cabeceras

Amplía el cuadrante para que cargue los empleados de un departamento, y haz que alternar anuncie el cambio con LiveAnnouncer, por ejemplo «Luis, martes: turno».

Ejercicio 10 (propuesto): Auditoría de una pantalla

Elige una pantalla del módulo 20 o una tuya. Pasa axe con el popup abierto y el formulario con errores, repite la comprobación manual de la sección 15 y anota cada problema, su criterio WCAG y la corrección.


Resumen

Concepto API Idea clave
Árbol de accesibilidad Panel Accessibility de DevTools Rol, nombre y estado son lo que llega al lector
Primera regla de ARIA HTML nativo ARIA cambia lo que se anuncia, no el comportamiento
Enlaces ARIA [attr.aria-*], [aria-*] false se escribe; null elimina el atributo
Roving tabindex focusMode="roving" Un solo tabindex="0"; las flechas lo mueven
Foco virtual aria-activedescendant El foco real se queda en el campo
Angular Aria @angular/aria/* Directivas sin estilos: tú pones HTML y CSS
Pestañas ngTabs, ngTabList, ngTab, ngTabPanel, ngTabContent Se enlazan por value; preserveContent conserva el estado
Acordeón ngAccordionGroup, ngAccordionTrigger, ngAccordionPanel Botón real en un encabezado; multiExpandable es true
Lista ngListbox, ngOption value es siempre un array (values en 21)
Combobox ngCombobox, ngComboboxPopup, ngComboboxWidget Filtras y cierras tú; API distinta en 21
Árbol ngTree, ngTreeItem, ngTreeItemGroup Recursión con plantillas; niveles automáticos
Menú y barra ngMenu, ngMenuTrigger, ngToolbar Menú para acciones, no para navegar
Cuadrícula ngGrid, ngGridRow, ngGridCell Solo si la tabla es interactiva
Foco desactivado softDisabled Por defecto se puede alcanzar
Selección selectionMode follow al moverse; explicit con Intro o Espacio
Foco visible :focus-visible Nunca quites el contorno sin sustituirlo
Rutas NavigationEnd, tabindex="-1" Mueve el foco a main tras navegar
Atrapar el foco cdkTrapFocus, cdkFocusInitial Mejor aún <dialog> nativo
Foco tras borrar afterNextRender Decide tú a dónde va
Origen del foco cdkMonitorElementFocus, FocusMonitor Distingue ratón y teclado
Anuncios LiveAnnouncer.announce polite por defecto; assertive con moderación
Región observada cdkAriaLive Anuncia los cambios de un texto
Estilos del CDK @angular/cdk/a11y-prebuilt.css Sin ellos, el anunciador se ve
Auditoría AxeBuilder, axe.run Cero infracciones no significa accesible
Harnesses @angular/aria/*/testing Solo en 22; prueban como un usuario