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/ariay 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
LiveAnnouncerycdkAriaLive - 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/ariaes 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@21y@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.
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);
}

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);
}

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. Conalt=""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:
falsese escribe como el texto"false". Es justo lo que quieres enaria-pressedoaria-expanded, cuyo valor debe existir también cuando es falso.nullelimina el atributo. Por eso[attr.aria-describedby]="error() ? 'correo-error' : null"no deja una referencia rota cuando no hay error.
⚠️ Importante:
aria-hidden="true"yrole="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-hiddensirve 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:
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 tienentabindex="-1". Al pulsar una flecha, el0pasa 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 eliddel 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/ariapasa de developer preview a estable. Si usas la LTS: instalanpm 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.
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:
softDisabledes 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()}`);
}

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 poneinert, 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);
}

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-expandedyaria-controlsal botón, yrole="region"yaria-labelledbyal panel. expandedes unmodel():[expanded]="true"deja el panel abierto al inicio, y[(expanded)]lo sincroniza con una señal.AccordionGroupofreceexpandAll()ycollapseAll(), yviewChildte 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:
multiExpandableestruepor defecto. Para permitir un solo panel abierto debes poner[multiExpandable]="false". En ese modoexpandAll()no hace nada.wrapesfalse. 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.
Listboxpasa a estable y su modelo de selección se llamavalueen lugar devalues. 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[]>([]);
}

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.
Comboboxes estable y tiene otra estructura:ngComboboxva directamente en elinput, y el popup usangComboboxPopupyngComboboxWidget. Si usas la LTS: envuelve elinputen<div ngCombobox filterMode="manual">, marca el campo conngComboboxInputy 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 elinput. Gestionaexpandedy el texto (value).ngComboboxPopup, en unng-template. Se renderiza cuando el combobox se expande.ngComboboxWidget, sobre la lista. Comunica suidy 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();
}
}

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
computedsobre una lista local, como en el ejercicio 5.ngComboboxno filtra nada: solo muestra lo que tú pongas en el popup. confirmarlo pones tú. La lista no cierra el combobox: tú enlazas(click)y(keydown.enter), y tú cierras conabierto.set(false).selectionMode="explicit"evita elegir a alguien por el simple hecho de pasar por encima con las flechas.- El
empty. El CSS.sugerencias:emptyoculta 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',
);
}

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, peroaria-expandedsigue entruey la lista queda abierta.
💡 Consejo: Aquí el popup se coloca con
position: absolute, lo más sencillo. Si un contenedor conoverflow: hiddenlo recorta, la guía oficial lo combina concdkConnectedOverlay, 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.
Treepasa a estable y su modelo de selección se llamavalueen lugar devalues. 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[]>([]);
}

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:
ngTemplateOutletdibuja la plantilla#nodoscon los nodos raíz. El padre es el propioTree, que llega en el contexto.- Cada
linecesita saber quién es su padre, y lo recibe por[parent]. - Si un nodo tiene hijas, se crea un
ngTreeItemGroup, cuyo[ownedBy]apunta allide arriba. - Dentro del grupo, la misma plantilla
#nodosse dibuja otra vez con las hijas, y esta vez el padre esgrupo.
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 eltextContentdel elemento, y ellide 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
valuees obligatorio enngToolbarWidgetyngMenuItem, yngMenuBarusavalues; añadevalue="exportar"a cada control de la barra. En la cuadrícula,enableRangeSelectionexiste 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;
});
}
}

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();
}
}

Figura 13. Es lo primero que encuentra el teclado al pulsar Tab. El ratón nunca lo ve.
Puntos clave:
tabindex="-1"permite enfocarmainpor 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.
preventDefaultes necesario porque, con un<base href>,#contenidoapuntaría a la raíz de la aplicación.takeUntilDestroyedcancela 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 },
);
}
}

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-labelda a cada botón un nombre propio. Sin él, el lector anunciaría tres veces «Borrar», sin decir a quién.afterNextRenderespera a que el DOM esté actualizado, como viste en el módulo 03. Si llamases afocusjusto después deupdate, 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 usarole="dialog".
💡 Consejo: Este ejemplo muestra cómo funciona la gestión del foco. En un proyecto real, usa antes
<dialog>conshowModal(), que hace inerte el resto de la página, oMatDialogy elDialogdel CDK, que viste en el módulo 20.cdkTrapFocussolo controla el foco: el resto sigue accesible para el lector, que debe confiar enaria-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. Unplaceholderno 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-invalidmarca el campo, yaria-describedbylo une al mensaje de error.novalidateevita 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
- Desconecta el ratón. ¿Llegas a todo con Tab? ¿Se ve siempre el foco? ¿Puedes salir de cada componente?
- Revisa el orden. ¿Sigue el foco un orden lógico?
- Amplía al 200 %. ¿Se corta algún contenido?
- Enciende un lector de pantalla. NVDA es gratuito en Windows, y VoiceOver viene con macOS. ¿Se entiende cada control sin verlo?
- 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);
}
}
![]()
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[]>([]);
}

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 |