La lliçó anterior ha comprovat, amb proves automatitzades, que <task-card> fa allò que s'espera d'ella: mostra el títol correcte, canvia d'insígnia segons l'estat, s'expandeix en fer clic. Però totes aquestes comprovacions assumeixen, de forma implícita, que qui utilitza TaskFlow ho fa amb un ratolí i una pantalla convencional. Aquesta lliçó revisa aquesta suposició: què li passa a l'accessibilitat d'un component quan el seu contingut viu dins d'un Shadow DOM, quins rols i atributs ARIA calen perquè <task-card> i <task-filter> tinguin sentit per a qui navega amb teclat o amb un lector de pantalla, i com gestionar el focus quan una interacció canvia visualment el contingut d'un component.
Contingut
- El Shadow DOM i l'accessibilitat: què canvia i què no
- El límit real: les relacions ARIA per
idno travessen el shadow root - Rols i atributs ARIA en un element personalitzat
aria-live: anunciar actualitzacions dinàmiques- Gestió del focus dins d'un component
delegatesFocus: delegar el focus al primer element focusable- Auditant
<task-card>: d'<article>clicable a control accessible - Auditant
<task-filter>: etiquetes i estat dels botons - Verificar amb les proves del mòdul
- El Shadow DOM i l'accessibilitat: què canvia i què no
Abans d'entrar en els problemes concrets, convé desfer una idea equivocada bastant estesa: el Shadow DOM no trenca l'accessibilitat per si sol. Un lector de pantalla, en recórrer la pàgina, travessa l'arbre d'accessibilitat complet, inclòs el contingut que viu dins de qualsevol shadow root, exactament igual que un navegador pinta a la pantalla aquest mateix contingut sense que l'usuari percebi cap frontera visual. Un <h3> dins del shadow root de <task-card> s'anuncia com un encapçalament de nivell 3, igual que si estigués escrit directament al document principal; un <button> dins d'un shadow root continua sent un botó focusable i accionable amb teclat, amb el seu rol semàntic intacte.
Allò que sí que canvia, i és la font real de la majoria de problemes d'accessibilitat específics de Web Components, són els mecanismes d'ARIA que depenen de referències per id entre dos elements.
- El límit real: les relacions ARIA per
id no travessen el shadow root
id no travessen el shadow rootDiversos atributs ARIA, a l'estàndard general d'accessibilitat web, funcionen apuntant a l'id d'un altre element: aria-labelledby="id-del-titulo", aria-describedby="id-de-la-descripcion", aria-controls="id-del-panel". Tots ells assumeixen que l'element amb aquest id viu al mateix arbre d'ids que l'element que el referencia, i aquí és on el Shadow DOM introdueix un límit real: els ids no són globals a través de fronteres de shadow root. Un aria-labelledby escrit dins del shadow root d'un component no pot apuntar a un id que viu al document principal, fora d'aquest shadow root, i a l'inrevés: un atribut escrit al DOM lleuger, fora de qualsevol component, no pot apuntar a un id que només existeix dins del shadow root d'aquest component.
<!-- Això NO funciona: l'id viu dins d'un shadow root diferent --> <task-card aria-labelledby="titulo-externo"></task-card> <h2 id="titulo-externo">Tareas pendientes</h2>
Aquest exemple no llança cap error visible: el navegador simplement no troba cap coincidència per a aria-labelledby="titulo-externo" dins de l'arbre d'accessibilitat que li correspon a <task-card>, i l'atribut queda, a la pràctica, sense efecte. La solució, en la immensa majoria dels casos, és resoldre la relació dins del propi shadow root del component, no a través de les seves fronteres: si <task-card> necessita un aria-labelledby, l'id al qual apunta ha d'estar també dins del seu propi render(), mai fora.
| Situació | Funciona? |
|---|---|
aria-labelledby apunta a un id dins del mateix shadow root |
Sí |
aria-labelledby apunta a un id del document principal, des de dins d'un shadow root |
No |
aria-labelledby apunta a un id d'un altre shadow root diferent |
No |
Un atribut ARIA sense referència a id (aria-label, aria-expanded, role) |
Sí, sense cap limitació de frontera |
Aquesta taula explica, de passada, per què la resta d'aquesta lliçó es recolza sobretot en atributs ARIA que no depenen d'un id (aria-label, aria-expanded, aria-pressed, aria-live, role): són els que funcionen de forma predictible sense necessitat de raonar sobre fronteres de shadow root en absolut.
- Rols i atributs ARIA en un element personalitzat
Un element personalitzat, com <task-card> o <task-filter>, no té cap rol d'accessibilitat implícit pel simple fet d'estar registrat amb customElements.define: a diferència de <button> o <input>, que el navegador reconeix de forma nativa amb la seva semàntica ja incorporada, un element personalitzat es comporta, d'entrada, com un <div> genèric a efectes d'accessibilitat, tret que el seu propi render() inclogui elements amb semàntica nativa (com el <select> de <task-card>, que sí que aporta el seu propi rol de "combobox" sense necessitat de res addicional) o que se li assignin explícitament atributs ARIA.
L'atribut role declara quin tipus de control és un element a efectes d'accessibilitat, quan la seva etiqueta HTML no ho deixa clar per si sola:
render() {
return html`
<article role="button" tabindex="0" aria-expanded="${this.expandida}">
<!-- ...contingut... -->
</article>
`;
}role="button" li diu a qualsevol tecnologia d'assistència que aquest <article>, encara que no sigui un <button> natiu, s'ha de tractar com un: anunciar-se com un control accionable, no com un simple bloc de text. aria-label, alternativa o complement a un text visible, proporciona una etiqueta accessible quan el contingut visual d'un control no és suficient o no existeix (per exemple, un botó que només mostra una icona, sense cap text que un lector de pantalla pugui llegir directament).
render() {
return html`
<button aria-label="Eliminar tarea" @click="${this.notificarEliminacion}">
🗑
</button>
`;
}Sense aria-label, un lector de pantalla anunciaria aquest botó simplement com "paperera" o, pitjor, com un caràcter Unicode sense cap significat clar, depenent de com interpreti l'emoji; amb aria-label="Eliminar tarea", el text anunciat és explícit i descriu l'acció, independentment de quina icona s'utilitzi visualment.
aria-live: anunciar actualitzacions dinàmiques
aria-live: anunciar actualitzacions dinàmiquesEls atributs vistos fins ara descriuen la naturalesa d'un control en un moment donat, però TaskFlow té, des del mòdul 6, contingut que canvia sense cap interacció directa de l'usuari: l'avís "⏰ Está a punto de vencer" que <task-card> mostra quan ContadorTiempoRestanteController detecta que una tasca s'acosta a la seva data límit. Un usuari que veu la pantalla nota aquest canvi d'immediat; algú que utilitza un lector de pantalla, que només s'assabenta d'allò que passa en el moment en què decideix tornar a recórrer aquesta part concreta de la pàgina, podria no percebre'l mai si res ho anuncia de forma activa.
aria-live resol exactament aquest problema: marca una regió del document com una regió activa, el contingut de la qual, en canviar, s'anuncia automàticament pel lector de pantalla sense que l'usuari hagi de tornar a navegar fins allà.
render() {
return html`
<article>
<!-- ...resta de la targeta... -->
<p aria-live="polite">
${this._contadorTiempo.cercaDeVencer ? '⏰ Está a punto de vencer' : ''}
</p>
</article>
`;
}El valor "polite" (enfront de "assertive", l'altra opció habitual) indica que l'anunci ha d'esperar que el lector de pantalla acabi qualsevol altra lectura en curs abans d'interrompre amb el nou contingut, en lloc de tallar immediatament allò que s'estigui llegint en aquell instant. Per a un avís informatiu com aquest —important, però no crític ni urgent en el sentit de requerir una reacció immediata— "polite" és gairebé sempre l'elecció correcta; "assertive" es reserva per a avisos que de veritat no poden esperar (un error greu, una sessió que està a punt de caducar), i el seu ús indiscriminat tendeix a resultar més disruptiu que útil.
- Gestió del focus dins d'un component
El focus de teclat —quin element concret rep les properes pulsacions de tecla— és un altre aspecte que un component amb Shadow DOM gestiona exactament igual que qualsevol altre element del DOM: el mètode estàndard elemento.focus(), heretat d'HTMLElement, funciona sense cap diferència sobre un node dins d'un shadow root, i document.activeElement, des de fora, apunta al mateix element personalitzat que conté el focus (no directament al node intern enfocat, que queda accessible a través d'elementoPersonalizado.shadowRoot.activeElement).
Un cas típic a TaskFlow: en expandir <task-card> per mostrar el seu detall, té sentit moure el focus cap al contingut acabat d'aparèixer, perquè qui navega amb teclat no es quedi "perdut" sobre un element que ja no ocupa el mateix lloc visual.
willUpdate(changedProperties) {
// (codi de willUpdate ja existent per a fechaLimite, sense canvis)
}
updated(changedProperties) {
if (changedProperties.has('expandida') && this.expandida) {
this.shadowRoot.querySelector('.detalle')?.focus();
}
}Aquest fragment aprofita updated, el hook presentat al mòdul 6 per reaccionar a canvis ja reflectits al DOM: només quan expandida canvia i el seu nou valor és true (és a dir, just quan la targeta passa de contreta a expandida), busca el bloc .detalle acabat de renderitzar i li demana el focus. Perquè un <div> com .detalle pugui rebre el focus mitjançant .focus(), necessita a més un atribut tabindex (tabindex="-1" és l'opció habitual quan l'element ha de ser focalitzable mitjançant codi, però no ha de formar part de la seqüència de tabulació normal del teclat).
delegatesFocus: delegar el focus al primer element focusable
delegatesFocus: delegar el focus al primer element focusableExisteix una situació diferent, més senzilla, que convé diferenciar de la de l'apartat anterior: quan el propi element personalitzat, com un tot, hauria de comportar-se com si fos focusable, delegant aquest focus al primer element focusable del seu interior. Lit ofereix això mitjançant una opció del propi shadow root, declarada com a propietat estàtica de la classe:
class TaskFilter extends LitElement {
static shadowRootOptions = {
...LitElement.shadowRootOptions,
delegatesFocus: true,
};
// ...resta de la classe sense canvis...
}Amb delegatesFocus: true, si algú crida document.querySelector('task-filter').focus(), o si <task-filter> rep el focus en tabular fins a ell, el navegador delega automàticament aquest focus al primer element focusable dins del seu shadow root —en el cas de <task-filter>, l'<input> de cerca—, sense que el propi component hagi d'escriure cap lògica addicional per aconseguir-ho. És una opció especialment útil per a components que embolcallen, en última instància, un únic control interactiu principal (com un camp de text o un botó), on té sentit que el propi element personalitzat "sigui", a efectes de focus, aquest control intern.
- Auditant
<task-card>: d'<article> clicable a control accessible
<task-card>: d'<article> clicable a control accessibleAmb tota la teoria ja coberta, toca aplicar-la a l'<article> de <task-card>, que des del mòdul 3 escolta @click per alternar expandida sense cap tipus de suport d'accessibilitat: sense rol semàntic, sense indicació del seu estat expandit o contret, i sense cap manera d'activar-lo des del teclat (un <article> no és, per si mateix, un element focusable ni accionable amb la tecla Enter o Espai).
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();
}
}Quatre canvis, cadascun resolent un problema concret dels apartats anteriors: role="button" anuncia l'<article> com un control accionable, no com un simple bloc de contingut; tabindex="0" l'incorpora a la seqüència normal de tabulació del teclat (sense aquest atribut, un <article> mai rebria el focus, per molt role que se li assigni); aria-expanded="${this.expandida}" reflecteix, en tot moment, si la targeta està expandida o contreta, el mateix tipus d'informació que un <details> natiu comunicaria per si sol; i @keydown="${this._gestionarTeclaExpandir}", juntament amb el seu gestor, fa que les tecles Enter i Espai disparin la mateixa acció que el clic, exactament el comportament que qualsevol <button> natiu oferiria de fàbrica i que un role="button" sobre un element no natiu ha de reproduir manualment. event.preventDefault() en el cas de la tecla Espai evita, a més, que el navegador faci scroll de la pàgina cap avall, el seu comportament per defecte per a aquesta tecla sobre un element enfocat.
- Auditant
<task-filter>: etiquetes i estat dels botons
<task-filter>: etiquetes i estat dels botons<task-filter>, des de la lliçó "Context Compartit amb @lit/context", té un <input> sense cap etiqueta accessible associada (només un placeholder, que no compleix la mateixa funció: desapareix en el moment en què l'usuari escriu, i molts lectors de pantalla ni tan sols l'anuncien de forma consistent) i tres botons l'estat "actiu" dels quals només es comunica visualment, mitjançant la classe CSS activo gestionada amb 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" a l'<input> cobreix exactament el buit assenyalat abans: una etiqueta accessible estable, que no depèn de que el camp estigui buit per poder-se llegir. role="search" sobre el contenidor general identifica la regió completa com una zona de cerca, una convenció d'ARIA que alguns lectors de pantalla utilitzen per oferir una navegació directa a aquesta part de la pàgina. role="group" juntament amb aria-label="Filtrar por estado" agrupa els tres botons sota una etiqueta comuna, de manera que un lector de pantalla anunciï el conjunt com "Filtrar por estado, grup", en lloc de tres botons solts sense cap relació aparent entre ells.
El canvi més important, però, és aria-pressed="${estado === opcion}": és l'equivalent ARIA, per a un botó de tipus "alternar" (toggle), d'allò que classMap({ activo: ... }) ja feia de forma purament visual. Sense aquest atribut, algú que navega amb un lector de pantalla pot veure (o, en aquest cas, escoltar) el nom de cada botó, però no té cap manera de saber quin dels tres està seleccionat en un moment donat; aria-pressed resol exactament aquesta mancança, comunicant el mateix estat que la classe activo comunica visualment, sense que tots dos mecanismes entrin en conflicte entre ells (de fet, segueixen exactament la mateixa condició, estado === opcion, així que mai poden desincronitzar-se).
- Verificar amb les proves del mòdul
Els canvis d'aquest mòdul no només es comproven a simple oïda amb un lector de pantalla (encara que aquesta comprovació manual continua sent insubstituïble abans de donar per tancada qualsevol millora d'accessibilitat); també es poden verificar amb la mateixa eina de la lliçó anterior, estenent les proves ja existents:
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');
});Aquesta prova comprova, sense necessitat d'un lector de pantalla real, que el contracte d'accessibilitat es compleix: el rol correcte hi és present des del principi, i aria-expanded reflecteix fidelment l'estat intern expandida abans i després de la interacció, exactament el mateix patró de "simular interacció, esperar updateComplete, comprovar el resultat" ja practicat a la lliçó anterior.
Errors Comuns i Consells
- Utilitzar
aria-labelledbyoaria-describedbyapuntant a unidfora del shadow root del component: com s'ha explicat a l'apartat 2, aquesta relació simplement no funciona; l'idreferenciat ha de viure dins del mateix arbre d'ombra que l'atribut que l'utilitza. - Afegir
role="button"sensetabindexni gestió de teclat: unroleper si sol només canvia allò que anuncia un lector de pantalla, no el comportament real de l'element; sensetabindex="0"i sense un gestor dekeydownper a Enter i Espai, com s'ha vist a l'apartat 7, el control continua sent inaccessible per a qui navega exclusivament amb teclat, encara que "soni" correcte per a qui utilitza un lector de pantalla amb ratolí. - Utilitzar
aria-live="assertive"per a qualsevol actualització dinàmica, "per si de cas": com s'ha explicat a l'apartat 4, un ús indiscriminat d'assertiveinterromp constantment la lectura en curs, resultant més molest que útil;"polite"és l'opció correcta per a la gran majoria d'actualitzacions informatives, inclosa la de<task-card>en aquest mòdul. - Duplicar l'estat visual i l'estat accessible sense que tots dos es derivin de la mateixa font: si
aria-pressedes calculés amb una condició diferent de la que alimentaclassMap({ activo: ... }), tots dos podrien desincronitzar-se després d'un canvi futur en un dels dos; com s'ha assenyalat a l'apartat 8, mantenir-los derivats de la mateixa expressió (estado === opcion) evita aquest risc d'arrel.
Exercicis
- Afegeix a
<task-filter>unaria-live="polite"sobre un nou paràgraf que mostri quantes tasques coincideixen amb el filtre actual (per exemple, "3 tareas encontradas"), de manera que qui utilitzi un lector de pantalla s'assabenti del resultat del filtre sense haver de navegar manualment fins a la llista. - Explica, basant-te en l'apartat 2, per què no seria correcte que
<task-list>intentés escriurearia-labelledbya cada<task-card>apuntant a un<h2>que viu al propi shadow root de<task-list>, i quina alternativa (de les vistes a l'apartat 3) resoldria el mateix problema de donar a cada targeta una etiqueta accessible relacionada amb la llista que la conté. - Un company d'equip proposa eliminar
tabindex="0"de l'<article>a l'apartat 7, argumentant querole="button"ja hauria de ser suficient perquè l'element sigui focusable. Explica per què aquesta suposició és incorrecta, recolzant-te en l'explicació del propi apartat 7.
Solucions
render() {
return html`
<div class="filtro" role="search">
<!-- ...input i botons sense canvis... -->
<p aria-live="polite">${this.tareasFiltradas?.length ?? 0} tareas encontradas</p>
</div>
`;
}(Nota: donat que tareasFiltradas viu a <task-list>, no a <task-filter>, una versió completa d'aquest exercici necessitaria exposar aquest recompte com a part del propi valor de context de filtre, o mitjançant un segon context de només lectura publicat per <task-list>; el que és important per a aquest exercici és la mecànica d'aria-live sobre el paràgraf de resultat, no la via exacta per la qual el número arriba fins a <task-filter>.)
- Un
aria-labelledbyescrit dins del shadow root d'una<task-card>no pot apuntar a unidque viu dins del shadow root de<task-list>, exactament per la limitació explicada a l'apartat 2: elsids no travessen fronteres de shadow root, ni en un sentit ni en l'altre. L'alternativa correcta és utilitzararia-labeldirectament sobre cada<task-card>(per exemple,aria-label="Tarea: ${tarea.titulo}", calculat dins del propirender()de<task-card>, sense cap referència a unidextern), que no depèn de cap relació entre arbres d'ombra diferents. role="button"únicament informa les tecnologies d'assistència de quin tipus de control és l'element a efectes de semàntica d'accessibilitat; no canvia en absolut el comportament real de l'element subjacent en quant a focus de teclat. Un<article>, a diferència d'un<button>natiu, no forma part per defecte de la seqüència de tabulació del navegador ni accepta el focus mitjançant teclat, sense importar quinrolese li assigni; noméstabindex="0"l'incorpora realment a aquesta seqüència. Eliminar-lo deixaria un element que "sona" com un botó per a un lector de pantalla que ja l'hagi trobat per una altra via, però al qual mai es podria arribar tabulant amb el teclat, ni activar amb Enter o Espai sense focus previ.
Conclusió
Aquesta lliçó ha mostrat que el Shadow DOM, en si mateix, no perjudica l'accessibilitat d'un component, però sí que imposa un límit concret i real —les relacions ARIA basades en id no travessen les seves fronteres— que convé conèixer per no dependre'n sense adonar-se'n. Amb role, aria-expanded, aria-pressed i aria-live, a més de la gestió explícita del focus de teclat, <task-card> i <task-filter> són ara accessibles per a qui navega exclusivament amb teclat o amb un lector de pantalla, no només per a qui utilitza un ratolí sobre una pantalla convencional.
Amb l'accessibilitat ja coberta, queda un últim angle de qualitat transversal per revisar abans de tancar el mòdul: el rendiment. La lliçó següent recupera diverses pràctiques ja insinuades en mòduls anteriors —repeat amb clau, willUpdate per a càlculs derivats, evitar treball innecessari a render()— i les aplica amb criteri explícit sobre <task-list> i <task-board> enfront d'una llista de tasques molt més gran de l'habitual.
Curs de Lit
Mòdul 1: Introducció a Lit i Web Components
- Què són els Web Components i per què Lit?
- Configuració de l'Entorn de Desenvolupament
- El Teu Primer Component Lit
- Anatomia d'un Component Lit
Mòdul 2: Plantilles Reactives i Renderitzat
- El Motor de Plantilles de Lit
- Expressions i Interpolació en Plantilles
- Renderitzat Condicional
- Renderitzat de Llistes
- El Cicle de Renderitzat
Mòdul 3: Propietats i Estat Reactiu
- Propietats Reactives
- Estat Intern amb @state
- Tipus de Propietats i Conversors Personalitzats
- Atributs vs Propietats i Reflexió
Mòdul 4: Estils en Components Lit
- CSS Encapsulat amb Shadow DOM
- Estils Compartits entre Components
- Variables CSS Personalitzades i Theming
- Slots i Estilitzat de Contingut Distribuït
Mòdul 5: Esdeveniments i Comunicació entre Components
- Gestió d'Esdeveniments DOM en Plantilles
- Esdeveniments Personalitzats: Comunicació de Fill a Pare
- Comunicació de Pare a Fill amb Propietats
- Patrons de Comunicació entre Components Germans
Mòdul 6: Cicle de Vida i Comportament Avançat
- Callbacks del Cicle de Vida
- Hooks Reactius: willUpdate, updated i firstUpdated
- Controladors Reactius
- Mixins i Composició de Comportament
Mòdul 7: Directives i Funcionalitats Avançades de Plantilles
- Directives Incorporades: classMap, styleMap i ifDefined
- Directives Personalitzades
- Renderitzat Asíncron amb until
- Context Compartit amb @lit/context
Mòdul 8: Integració, Interoperabilitat i Desplegament
- Utilitzar Components Lit en HTML Pla
- Integrar Lit amb React, Vue i Angular
- Renderitzat al Servidor amb @lit-labs/ssr
- Empaquetatge, Publicació i TypeScript
Mòdul 9: Proves i Bones Pràctiques
- Proves Unitàries amb Web Test Runner
- Accessibilitat en Web Components
- Rendiment i Optimització
- Patrons i Antipatrons Comuns
