La lección anterior ha comprobado, con tests automatizados, que <task-card> hace lo que se espera de ella: muestra el título correcto, cambia de insignia según el estado, se expande al hacer clic. Pero todas esas comprobaciones asumen, de forma implícita, que quien usa TaskFlow lo hace con un ratón y una pantalla convencional. Esta lección revisa esa suposición: qué le ocurre a la accesibilidad de un componente cuando su contenido vive dentro de un Shadow DOM, qué roles y atributos ARIA hacen falta para que <task-card> y <task-filter> tengan sentido para quien navega con teclado o con un lector de pantalla, y cómo gestionar el foco cuando una interacción cambia visualmente el contenido de un componente.
Contenido
- El Shadow DOM y la accesibilidad: lo que cambia y lo que no
- El límite real: relaciones ARIA por
idno atraviesan el shadow root - Roles y atributos ARIA en un elemento personalizado
aria-live: anunciar actualizaciones dinámicas- Gestión del foco dentro de un componente
delegatesFocus: delegar el foco al primer elemento focusable- Auditando
<task-card>: de<article>clicable a control accesible - Auditando
<task-filter>: etiquetas y estado de los botones - Verificar con los tests del módulo
- El Shadow DOM y la accesibilidad: lo que cambia y lo que no
Antes de entrar en los problemas concretos, conviene despejar una idea equivocada bastante extendida: el Shadow DOM no rompe la accesibilidad por sí solo. Un lector de pantalla, al recorrer la página, atraviesa el árbol de accesibilidad completo, incluido el contenido que vive dentro de cualquier shadow root, exactamente igual que un navegador pinta en pantalla ese mismo contenido sin que el usuario perciba ninguna frontera visual. Un <h3> dentro del shadow root de <task-card> se anuncia como un encabezado de nivel 3, igual que si estuviera escrito directamente en el documento principal; un <button> dentro de un shadow root sigue siendo un botón focusable y accionable con teclado, con su rol semántico intacto.
Lo que sí cambia, y es la fuente real de la mayoría de los problemas de accesibilidad específicos de Web Components, son los mecanismos de ARIA que dependen de referencias por id entre dos elementos.
- El límite real: relaciones ARIA por
id no atraviesan el shadow root
id no atraviesan el shadow rootVarios atributos ARIA, en el estándar general de accesibilidad web, funcionan apuntando al id de otro elemento: aria-labelledby="id-del-titulo", aria-describedby="id-de-la-descripcion", aria-controls="id-del-panel". Todos ellos asumen que el elemento con ese id vive en el mismo árbol de ids que el elemento que lo referencia, y aquí es donde el Shadow DOM introduce un límite real: los ids no son globales a través de fronteras de shadow root. Un aria-labelledby escrito dentro del shadow root de un componente no puede apuntar a un id que vive en el documento principal, fuera de ese shadow root, y viceversa: un atributo escrito en el DOM ligero, fuera de cualquier componente, no puede apuntar a un id que solo existe dentro del shadow root de ese componente.
<!-- Esto NO funciona: el id vive dentro de un shadow root distinto --> <task-card aria-labelledby="titulo-externo"></task-card> <h2 id="titulo-externo">Tareas pendientes</h2>
Este ejemplo no lanza ningún error visible: el navegador simplemente no encuentra ninguna coincidencia para aria-labelledby="titulo-externo" dentro del árbol de accesibilidad que le corresponde a <task-card>, y el atributo queda, en la práctica, sin efecto. La solución, en la inmensa mayoría de los casos, es resolver la relación dentro del propio shadow root del componente, no a través de sus fronteras: si <task-card> necesita un aria-labelledby, el id al que apunta debe estar también dentro de su propio render(), nunca fuera.
| Situación | ¿Funciona? |
|---|---|
aria-labelledby apunta a un id dentro del mismo shadow root |
Sí |
aria-labelledby apunta a un id del documento principal, desde dentro de un shadow root |
No |
aria-labelledby apunta a un id de otro shadow root distinto |
No |
Un atributo ARIA sin referencia a id (aria-label, aria-expanded, role) |
Sí, sin ninguna limitación de frontera |
Esta tabla explica, de paso, por qué el resto de esta lección se apoya sobre todo en atributos ARIA que no dependen de un id (aria-label, aria-expanded, aria-pressed, aria-live, role): son los que funcionan de forma predecible sin necesidad de razonar sobre fronteras de shadow root en absoluto.
- Roles y atributos ARIA en un elemento personalizado
Un elemento personalizado, como <task-card> o <task-filter>, no tiene ningún rol de accesibilidad implícito por el mero hecho de estar registrado con customElements.define: a diferencia de <button> o <input>, que el navegador reconoce de forma nativa con su semántica ya incorporada, un elemento personalizado se comporta, de entrada, como un <div> genérico a efectos de accesibilidad, salvo que su propio render() incluya elementos con semántica nativa (como el <select> de <task-card>, que sí aporta su propio rol de "combobox" sin necesidad de nada adicional) o que se le asignen explícitamente atributos ARIA.
El atributo role declara qué tipo de control es un elemento a efectos de accesibilidad, cuando su etiqueta HTML no lo deja claro por sí misma:
render() {
return html`
<article role="button" tabindex="0" aria-expanded="${this.expandida}">
<!-- ...contenido... -->
</article>
`;
}role="button" le dice a cualquier tecnología de asistencia que ese <article>, aunque no sea un <button> nativo, debe tratarse como uno: anunciarse como un control accionable, no como un simple bloque de texto. aria-label, alternativa u complemento a un texto visible, proporciona una etiqueta accesible cuando el contenido visual de un control no es suficiente o no existe (por ejemplo, un botón que solo muestra un icono, sin ningún texto que un lector de pantalla pueda leer directamente).
render() {
return html`
<button aria-label="Eliminar tarea" @click="${this.notificarEliminacion}">
🗑
</button>
`;
}Sin aria-label, un lector de pantalla anunciaría este botón simplemente como "papelera" o, peor, como un carácter Unicode sin ningún significado claro, dependiendo de cómo interprete el emoji; con aria-label="Eliminar tarea", el texto anunciado es explícito y describe la acción, independientemente de qué icono se use visualmente.
aria-live: anunciar actualizaciones dinámicas
aria-live: anunciar actualizaciones dinámicasLos atributos vistos hasta ahora describen la naturaleza de un control en un momento dado, pero TaskFlow tiene, desde el módulo 6, contenido que cambia sin ninguna interacción directa del usuario: el aviso "⏰ Está a punto de vencer" que <task-card> muestra cuando ContadorTiempoRestanteController detecta que una tarea se acerca a su fecha límite. Un usuario que ve la pantalla nota ese cambio de inmediato; alguien que usa un lector de pantalla, que solo se entera de lo que ocurre en el momento en que decide volver a recorrer esa parte concreta de la página, podría no percibirlo nunca si nada lo anuncia de forma activa.
aria-live resuelve exactamente este problema: marca una región del documento como una región activa, cuyo contenido, al cambiar, se anuncia automáticamente por el lector de pantalla sin que el usuario tenga que volver a navegar hasta ahí.
render() {
return html`
<article>
<!-- ...resto de la tarjeta... -->
<p aria-live="polite">
${this._contadorTiempo.cercaDeVencer ? '⏰ Está a punto de vencer' : ''}
</p>
</article>
`;
}El valor "polite" (frente a "assertive", la otra opción habitual) indica que el anuncio debe esperar a que el lector de pantalla termine cualquier otra lectura en curso antes de interrumpir con el nuevo contenido, en lugar de cortar inmediatamente lo que se esté leyendo en ese instante. Para un aviso informativo como este —importante, pero no crítico ni urgente en el sentido de requerir una reacción inmediata— "polite" es casi siempre la elección correcta; "assertive" se reserva para avisos que de verdad no pueden esperar (un error grave, una sesión que está a punto de caducar), y su uso indiscriminado tiende a resultar más disruptivo que útil.
- Gestión del foco dentro de un componente
El foco de teclado —qué elemento concreto recibe las siguientes pulsaciones de tecla— es otro aspecto que un componente con Shadow DOM gestiona exactamente igual que cualquier otro elemento del DOM: el método estándar elemento.focus(), heredado de HTMLElement, funciona sin ninguna diferencia sobre un nodo dentro de un shadow root, y document.activeElement, desde fuera, apunta al propio elemento personalizado que contiene el foco (no directamente al nodo interno enfocado, que queda accesible a través de elementoPersonalizado.shadowRoot.activeElement).
Un caso típico en TaskFlow: al expandir <task-card> para mostrar su detalle, tiene sentido mover el foco hacia el contenido recién aparecido, para que quien navega con teclado no se quede "perdido" sobre un elemento que ya no ocupa el mismo lugar visual.
willUpdate(changedProperties) {
// (código de willUpdate ya existente para fechaLimite, sin cambios)
}
updated(changedProperties) {
if (changedProperties.has('expandida') && this.expandida) {
this.shadowRoot.querySelector('.detalle')?.focus();
}
}Este fragmento aprovecha updated, el hook presentado en el módulo 6 para reaccionar a cambios ya reflejados en el DOM: solo cuando expandida cambia y su nuevo valor es true (es decir, justo cuando la tarjeta pasa de contraída a expandida), busca el bloque .detalle recién renderizado y le pide el foco. Para que un <div> como .detalle pueda recibir el foco mediante .focus(), necesita además un atributo tabindex (tabindex="-1" es la opción habitual cuando el elemento debe ser focalizable mediante código, pero no debe formar parte de la secuencia de tabulación normal del teclado).
delegatesFocus: delegar el foco al primer elemento focusable
delegatesFocus: delegar el foco al primer elemento focusableExiste una situación distinta, más simple, que conviene diferenciar de la del apartado anterior: cuando el propio elemento personalizado, como un todo, debería comportarse como si fuera focusable, delegando ese foco al primer elemento focusable de su interior. Lit ofrece esto mediante una opción del propio shadow root, declarada como propiedad estática de la clase:
class TaskFilter extends LitElement {
static shadowRootOptions = {
...LitElement.shadowRootOptions,
delegatesFocus: true,
};
// ...resto de la clase sin cambios...
}Con delegatesFocus: true, si alguien llama a document.querySelector('task-filter').focus(), o si <task-filter> recibe el foco al tabular hasta él, el navegador delega automáticamente ese foco al primer elemento focusable dentro de su shadow root —en el caso de <task-filter>, el <input> de búsqueda—, sin que el propio componente tenga que escribir ninguna lógica adicional para conseguirlo. Es una opción especialmente útil para componentes que envuelven, en última instancia, un único control interactivo principal (como un campo de texto o un botón), donde tiene sentido que el propio elemento personalizado "sea", a efectos de foco, ese control interno.
- Auditando
<task-card>: de <article> clicable a control accesible
<task-card>: de <article> clicable a control accesibleCon toda la teoría ya cubierta, toca aplicarla al <article> de <task-card>, que desde el módulo 3 escucha @click para alternar expandida sin ningún tipo de soporte de accesibilidad: sin rol semántico, sin indicación de su estado expandido o contraído, y sin ninguna forma de activarlo desde el teclado (un <article> no es, por sí mismo, un elemento focusable ni accionable con la tecla Enter o Espacio).
render() {
return html`
<article
role="button"
tabindex="0"
aria-expanded="${this.expandida}"
@click="${this.alternarExpandida}"
@keydown="${this._gestionarTeclaExpandir}"
>
<div class="cabecera">
${this.renderAvatar()}
<h3>${this.titulo}</h3>
</div>
${this.renderInsigniaEstado()}
${this.renderSelectorEstado()}
<p>Prioridad: ${this.prioridad}</p>
${this.urgente && html`<p class="aviso">⚠ Urgente</p>`}
<p aria-live="polite">
${this._contadorTiempo.cercaDeVencer ? '⏰ Está a punto de vencer' : ''}
</p>
${this.expandida
? html`<div class="detalle" tabindex="-1"><p>Estado interno: la tarjeta está expandida.</p></div>`
: ''}
</article>
`;
}
_gestionarTeclaExpandir(event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.alternarExpandida();
}
}Cuatro cambios, cada uno resolviendo un problema concreto de los apartados anteriores: role="button" anuncia el <article> como un control accionable, no como un simple bloque de contenido; tabindex="0" lo incorpora a la secuencia normal de tabulación del teclado (sin este atributo, un <article> nunca recibiría el foco, por mucho role que se le asigne); aria-expanded="${this.expandida}" refleja, en todo momento, si la tarjeta está expandida o contraída, el mismo tipo de información que un <details> nativo comunicaría por sí solo; y @keydown="${this._gestionarTeclaExpandir}", junto con su manejador, hace que las teclas Enter y Espacio disparen la misma acción que el clic, exactamente el comportamiento que cualquier <button> nativo ofrecería de fábrica y que un role="button" sobre un elemento no nativo tiene que reproducir manualmente. event.preventDefault() en el caso de la tecla Espacio evita, además, que el navegador haga scroll de la página hacia abajo, su comportamiento por defecto para esa tecla sobre un elemento focusado.
- Auditando
<task-filter>: etiquetas y estado de los botones
<task-filter>: etiquetas y estado de los botones<task-filter>, desde la lección "Contexto Compartido con @lit/context", tiene un <input> sin ninguna etiqueta accesible asociada (solo un placeholder, que no cumple la misma función: desaparece en cuanto el usuario escribe, y muchos lectores de pantalla ni siquiera lo anuncian de forma consistente) y tres botones cuyo estado "activo" solo se comunica visualmente, mediante la clase CSS activo gestionada con classMap.
render() {
const { texto, estado } = this.valorActual;
return html`
<div class="filtro" role="search">
<input
type="text"
aria-label="Buscar tarea por título"
placeholder="Buscar tarea…"
.value="${texto}"
@input="${this.manejarTexto}"
/>
<div class="filtro__botones" role="group" aria-label="Filtrar por estado">
${['todas', 'pendiente', 'hecha'].map(
(opcion) => html`
<button
class="${classMap({ activo: estado === opcion })}"
aria-pressed="${estado === opcion}"
@click="${() => this.manejarEstado(opcion)}"
>
${{ todas: 'Todas', pendiente: 'Pendientes', hecha: 'Hechas' }[opcion]}
</button>
`
)}
</div>
</div>
`;
}aria-label="Buscar tarea por título" en el <input> cubre exactamente el vacío señalado antes: una etiqueta accesible estable, que no depende de que el campo esté vacío para poder leerse. role="search" sobre el contenedor general identifica la región completa como una zona de búsqueda, una convención de ARIA que algunos lectores de pantalla usan para ofrecer una navegación directa a esa parte de la página. role="group" junto con aria-label="Filtrar por estado" agrupa los tres botones bajo una etiqueta común, de forma que un lector de pantalla anuncie el conjunto como "Filtrar por estado, grupo", en lugar de tres botones sueltos sin ninguna relación aparente entre ellos.
El cambio más importante, sin embargo, es aria-pressed="${estado === opcion}": es el equivalente ARIA, para un botón de tipo "alternar" (toggle), de lo que classMap({ activo: ... }) ya hacía de forma puramente visual. Sin este atributo, alguien que navega con un lector de pantalla puede ver (o, en este caso, escuchar) el nombre de cada botón, pero no tiene ninguna forma de saber cuál de los tres está seleccionado en un momento dado; aria-pressed resuelve exactamente esa carencia, comunicando el mismo estado que la clase activo comunica visualmente, sin que ambos mecanismos entren en conflicto entre sí (de hecho, siguen exactamente la misma condición, estado === opcion, así que jamás pueden desincronizarse).
- Verificar con los tests del módulo
Los cambios de este módulo no solo se comprueban a simple oído con un lector de pantalla (aunque esa comprobación manual sigue siendo insustituible antes de dar por cerrada cualquier mejora de accesibilidad); también se pueden verificar con la misma herramienta de la lección anterior, extendiendo los tests ya existentes:
it('expone role="button" y aria-expanded en el article', async () => {
const el = await fixture(html`<task-card></task-card>`);
const articulo = el.shadowRoot.querySelector('article');
expect(articulo.getAttribute('role')).to.equal('button');
expect(articulo.getAttribute('aria-expanded')).to.equal('false');
articulo.click();
await el.updateComplete;
expect(articulo.getAttribute('aria-expanded')).to.equal('true');
});Este test comprueba, sin necesidad de un lector de pantalla real, que el contrato de accesibilidad se cumple: el rol correcto está presente desde el principio, y aria-expanded refleja fielmente el estado interno expandida antes y después de la interacción, exactamente el mismo patrón de "simular interacción, esperar updateComplete, comprobar el resultado" ya practicado en la lección anterior.
Errores Comunes y Consejos
- Usar
aria-labelledbyoaria-describedbyapuntando a unidfuera del shadow root del componente: como se explicó en el apartado 2, esa relación simplemente no funciona; elidreferenciado debe vivir dentro del mismo árbol de sombra que el atributo que lo usa. - Añadir
role="button"sintabindexni gestión de teclado: unrolepor sí solo solo cambia lo que anuncia un lector de pantalla, no el comportamiento real del elemento; sintabindex="0"y sin un manejador dekeydownpara Enter y Espacio, como se ha visto en el apartado 7, el control sigue siendo inaccesible para quien navega exclusivamente con teclado, aunque "suene" correcto para quien usa un lector de pantalla con ratón. - Usar
aria-live="assertive"para cualquier actualización dinámica, "por si acaso": como se explicó en el apartado 4, un uso indiscriminado deassertiveinterrumpe constantemente la lectura en curso, resultando más molesto que útil;"polite"es la opción correcta para la gran mayoría de actualizaciones informativas, incluida la de<task-card>en este módulo. - Duplicar el estado visual y el estado accesible sin que ambos se deriven de la misma fuente: si
aria-pressedse calculara con una condición distinta a la que alimentaclassMap({ activo: ... }), ambos podrían desincronizarse tras un cambio futuro en uno de los dos; como se ha señalado en el apartado 8, mantenerlos derivados de la misma expresión (estado === opcion) evita ese riesgo de raíz.
Ejercicios
- Añade a
<task-filter>unaria-live="polite"sobre un nuevo párrafo que muestre cuántas tareas coinciden con el filtro actual (por ejemplo, "3 tareas encontradas"), de forma que quien use un lector de pantalla se entere del resultado del filtro sin tener que navegar manualmente hasta la lista. - Explica, basándote en el apartado 2, por qué no sería correcto que
<task-list>intentara escribiraria-labelledbyen cada<task-card>apuntando a un<h2>que vive en el propio shadow root de<task-list>, y qué alternativa (de las vistas en el apartado 3) resolvería el mismo problema de darle a cada tarjeta una etiqueta accesible relacionada con la lista que la contiene. - Un compañero de equipo propone eliminar
tabindex="0"de<article>en el apartado 7, argumentando querole="button"ya debería bastar para que el elemento sea focusable. Explica por qué esa suposición es incorrecta, apoyándote en la explicación del propio apartado 7.
Soluciones
render() {
return html`
<div class="filtro" role="search">
<!-- ...input y botones sin cambios... -->
<p aria-live="polite">${this.tareasFiltradas?.length ?? 0} tareas encontradas</p>
</div>
`;
}(Nota: dado que tareasFiltradas vive en <task-list>, no en <task-filter>, una versión completa de este ejercicio necesitaría exponer ese recuento como parte del propio valor de contexto de filtro, o mediante un segundo contexto de solo lectura publicado por <task-list>; lo importante para este ejercicio es la mecánica de aria-live sobre el párrafo de resultado, no la vía exacta por la que el número llega hasta <task-filter>.)
- Un
aria-labelledbyescrito dentro del shadow root de una<task-card>no puede apuntar a unidque vive dentro del shadow root de<task-list>, exactamente por la limitación explicada en el apartado 2: losids no atraviesan fronteras de shadow root, ni en un sentido ni en el otro. La alternativa correcta es usararia-labeldirectamente sobre cada<task-card>(por ejemplo,aria-label="Tarea: ${tarea.titulo}", calculado dentro del propiorender()de<task-card>, sin ninguna referencia a unidexterno), que no depende de ninguna relación entre árboles de sombra distintos. role="button"únicamente informa a las tecnologías de asistencia de qué tipo de control es el elemento a efectos de semántica de accesibilidad; no cambia en absoluto el comportamiento real del elemento subyacente en cuanto a foco de teclado. Un<article>, a diferencia de un<button>nativo, no forma parte por defecto de la secuencia de tabulación del navegador ni acepta el foco mediante teclado, sin importar quérolese le asigne; solotabindex="0"lo incorpora realmente a esa secuencia. Eliminarlo dejaría un elemento que "suena" como un botón para un lector de pantalla que ya lo haya encontrado por otra vía, pero al que jamás se podría llegar tabulando con el teclado, ni activar con Enter o Espacio sin foco previo.
Conclusión
Esta lección ha mostrado que el Shadow DOM, en sí mismo, no perjudica la accesibilidad de un componente, pero sí impone un límite concreto y real —las relaciones ARIA basadas en id no atraviesan sus fronteras— que conviene conocer para no depender de ellas sin darse cuenta. Con role, aria-expanded, aria-pressed y aria-live, además de la gestión explícita del foco de teclado, <task-card> y <task-filter> son ahora accesibles para quien navega exclusivamente con teclado o con un lector de pantalla, no solo para quien usa un ratón sobre una pantalla convencional.
Con la accesibilidad ya cubierta, queda un último ángulo de calidad transversal por revisar antes de cerrar el módulo: el rendimiento. La siguiente lección retoma varias prácticas ya insinuadas en módulos anteriores —repeat con clave, willUpdate para cálculos derivados, evitar trabajo innecesario en render()— y las aplica con criterio explícito sobre <task-list> y <task-board> frente a una lista de tareas mucho más grande de lo habitual.
Curso de Lit
Módulo 1: Introducción a Lit y Web Components
- ¿Qué son los Web Components y por qué Lit?
- Configuración del Entorno de Desarrollo
- Tu Primer Componente Lit
- Anatomía de un Componente Lit
Módulo 2: Plantillas Reactivas y Renderizado
- El Motor de Plantillas de Lit
- Expresiones e Interpolación en Plantillas
- Renderizado Condicional
- Renderizado de Listas
- El Ciclo de Renderizado
Módulo 3: Propiedades y Estado Reactivo
- Propiedades Reactivas
- Estado Interno con @state
- Tipos de Propiedades y Conversores Personalizados
- Atributos vs Propiedades y Reflexión
Módulo 4: Estilos en Componentes Lit
- CSS Encapsulado con Shadow DOM
- Estilos Compartidos entre Componentes
- Variables CSS Personalizadas y Theming
- Slots y Estilizado de Contenido Distribuido
Módulo 5: Eventos y Comunicación entre Componentes
- Manejo de Eventos DOM en Plantillas
- Eventos Personalizados: Comunicación Hijo a Padre
- Comunicación de Padre a Hijo con Propiedades
- Patrones de Comunicación entre Componentes Hermanos
Módulo 6: Ciclo de Vida y Comportamiento Avanzado
- Callbacks del Ciclo de Vida
- Hooks Reactivos: willUpdate, updated y firstUpdated
- Controladores Reactivos
- Mixins y Composición de Comportamiento
Módulo 7: Directivas y Funcionalidades Avanzadas de Plantillas
- Directivas Incorporadas: classMap, styleMap e ifDefined
- Directivas Personalizadas
- Renderizado Asíncrono con until
- Contexto Compartido con @lit/context
Módulo 8: Integración, Interoperabilidad y Despliegue
- Usar Componentes Lit en HTML Plano
- Integrar Lit con React, Vue y Angular
- Renderizado en el Servidor con @lit-labs/ssr
- Empaquetado, Publicación y TypeScript
Módulo 9: Pruebas y Buenas Prácticas
- Pruebas Unitarias con Web Test Runner
- Accesibilidad en Web Components
- Rendimiento y Optimización
- Patrones y Anti-patrones Comunes
