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

  1. El Shadow DOM i l'accessibilitat: què canvia i què no
  2. El límit real: les relacions ARIA per id no travessen el shadow root
  3. Rols i atributs ARIA en un element personalitzat
  4. aria-live: anunciar actualitzacions dinàmiques
  5. Gestió del focus dins d'un component
  6. delegatesFocus: delegar el focus al primer element focusable
  7. Auditant <task-card>: d'<article> clicable a control accessible
  8. Auditant <task-filter>: etiquetes i estat dels botons
  9. Verificar amb les proves del mòdul

  1. 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.

  1. El límit real: les relacions ARIA per id no travessen el shadow root

Diversos 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
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.

  1. 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.

  1. aria-live: anunciar actualitzacions dinàmiques

Els 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.

  1. 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).

  1. delegatesFocus: delegar el focus al primer element focusable

Existeix 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.

  1. Auditant <task-card>: d'<article> clicable a control accessible

Amb 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.

  1. Auditant <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).

  1. 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-labelledby o aria-describedby apuntant a un id fora del shadow root del component: com s'ha explicat a l'apartat 2, aquesta relació simplement no funciona; l'id referenciat ha de viure dins del mateix arbre d'ombra que l'atribut que l'utilitza.
  • Afegir role="button" sense tabindex ni gestió de teclat: un role per si sol només canvia allò que anuncia un lector de pantalla, no el comportament real de l'element; sense tabindex="0" i sense un gestor de keydown per 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'assertive interromp 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-pressed es calculés amb una condició diferent de la que alimenta classMap({ 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

  1. Afegeix a <task-filter> un aria-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.
  2. Explica, basant-te en l'apartat 2, per què no seria correcte que <task-list> intentés escriure aria-labelledby a 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é.
  3. Un company d'equip proposa eliminar tabindex="0" de l'<article> a l'apartat 7, argumentant que role="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>.)

  1. Un aria-labelledby escrit dins del shadow root d'una <task-card> no pot apuntar a un id que viu dins del shadow root de <task-list>, exactament per la limitació explicada a l'apartat 2: els ids no travessen fronteres de shadow root, ni en un sentit ni en l'altre. L'alternativa correcta és utilitzar aria-label directament sobre cada <task-card> (per exemple, aria-label="Tarea: ${tarea.titulo}", calculat dins del propi render() de <task-card>, sense cap referència a un id extern), que no depèn de cap relació entre arbres d'ombra diferents.
  2. 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 quin role se li assigni; només tabindex="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

Mòdul 2: Plantilles Reactives i Renderitzat

Mòdul 3: Propietats i Estat Reactiu

Mòdul 4: Estils en Components Lit

Mòdul 5: Esdeveniments i Comunicació entre Components

Mòdul 6: Cicle de Vida i Comportament Avançat

Mòdul 7: Directives i Funcionalitats Avançades de Plantilles

Mòdul 8: Integració, Interoperabilitat i Desplegament

Mòdul 9: Proves i Bones Pràctiques

Mòdul 10: Projecte: Construint TaskFlow

© Copyright 2026. Tots els drets reservats