Addaw, anar a la pàgina d'inici Donar

Lliçó 8 de 10 · 14 min de lectura · Revisada el 26/10/2026

Components reutilitzables i Web Components: API accessible, semàntica i Shadow DOM

Estàs llegint una lliçó oberta. Matricula't gratis per fer els tests i els exercicis, desar el teu progrés i aconseguir el certificat.

En la lliçó anterior vas resoldre l'accessibilitat a escala d'aplicació: rutes, títols i focus. Aquesta baixa al maó amb què es construeixen avui les interfícies: el component reutilitzable. Un botó, un camp o una targeta ben empaquetats repeteixen l'accessibilitat en cada pantalla; mal empaquetats, repeteixen l'error cent vegades. No es tracta dels patrons d'interacció (pestanyes, menús, diàlegs), que vas veure en el curs de components interactius, sinó de com es programa el component perquè el seu ús accessible sigui el camí fàcil: la seva API, el seu marcatge i, en el cas dels Web Components, la seva relació amb el Shadow DOM.

Dissenya l'API perquè el que és accessible sigui el que és fàcil

L'API d'un component són les propietats que accepta. Si el nom accessible és opcional, algú l'ometrà. Fes-lo obligatori i deixa que el sistema de tipus l'exigeixi:

Exemple

type BotonIconoProps = {
  icono: React.ReactNode;
  etiqueta: string;              // obligatori: sense ell no compila
  onClick: () => void;
};

export function BotonIcono({ icono, etiqueta, onClick }: BotonIconoProps) {
  return (
    <button type="button" onClick={onClick}>
      <span aria-hidden="true">{icono}</span>
      <span className="visually-hidden">{etiqueta}</span>
    </button>
  );
}

Qui faci servir <BotonIcono icono={<Papelera />} onClick={borrar} /> sense etiqueta rep un error de TypeScript a l'editor, molt abans que qualsevol auditoria.

Altres decisions d'API amb efecte directe en l'accessibilitat:

  • Semàntica segons l'ús: si el component rep href, que renderitzi un <a>; si rep una acció, un <button type="button">. Un únic component «Botó» que sempre pinta <button> i navega amb onClick trenca el menú contextual, l'«obre en una pestanya nova» i l'anunci d'«enllaç».
  • Nivells d'encapçalament configurables: una targeta que fixa un <h3> trencarà la jerarquia tan bon punt es faci servir en una altra pàgina. Accepta una propietat nivel amb un valor per defecte raonable.
  • Atributs que arriben a l'element correcte: id, aria-describedby, aria-invalid o autocomplete s'han d'aplicar a l'<input> real, no al div que l'embolcalla.
  • Accés a l'element: el component pare ha de poder enfocar el control (per portar el focus a un error o retornar-lo en tancar un diàleg). A React s'exposa amb forwardRef o, des de React 19, rebent ref com una propietat més; a Vue, amb referències de plantilla i defineExpose; a Angular, amb ElementRef i les consultes de vista.
  • Contingut, no només text: deixa que qui fa servir el component aporti contingut ric mitjançant children a React, slots a Vue o projecció de contingut a Angular, perquè no recorri a trucs com ficar HTML en una cadena.

Error freqüent

Un component de camp que reparteix tots els atributs rebuts sobre el contenidor exterior. A Vue passa per defecte: els atributs que no són propietats declarades s'apliquen a l'element arrel. El resultat és un <div aria-describedby="ayuda-dni" aria-invalid="true"> que el lector ignora i un <input> sense descripció ni estat d'error. La solució és desactivar l'herència i dirigir els atributs al control:

<script setup>
import { useId } from 'vue';
defineOptions({ inheritAttrs: false });
defineProps({ etiqueta: { type: String, required: true } });
const id = useId();
</script>

<template>
  <div class="campo">
    <label :for="id">{{ etiqueta }}</label>
    <input :id="id" v-bind="$attrs">
  </div>
</template>

A React, l'equivalent és recollir la resta de propietats (...resto) i estendre-les sobre l'<input>, no sobre l'embolcall.

Que l'embolcall no trenqui la semàntica

Algunes relacions de l'HTML exigeixen parentiu directe: un <li> ha de ser fill d'<ul> o d'<ol>, i les files i les cel·les d'una taula han d'estar al seu lloc. Els components tendeixen a ficar elements intermedis que trenquen aquestes relacions, i el lector deixa d'anunciar «llista, 8 elements» o perd l'associació entre cel·les i encapçalaments.

  • React: si un component ha de retornar diversos elements, fes servir un fragment (<>…</>) en lloc d'un div.
  • Vue 3: admet diversos nodes arrel, així que no cal un embolcall artificial.
  • Angular: cada component es renderitza dins del seu element amfitrió (<app-tramite>), que queda entre l'<ul> i el <li>. La solució és un selector d'atribut, de manera que l'amfitrió sigui el mateix element semàntic:
@Component({
  selector: 'li[appTramite]',
  template: `<a [href]="url">{{ titulo }}</a>`,
})
export class TramiteComponent {
  @Input() titulo = '';
  @Input() url = '';
}

<!-- Ús: la llista continua sent una llista -->
<ul>
  <li appTramite titulo="Empadronament" url="/padron"></li>
  <li appTramite titulo="Cita prèvia" url="/cita"></li>
</ul>

Vés amb compte amb display: contents com a drecera per «fer desaparèixer» un embolcall: durant anys diversos navegadors eliminaven amb ell la semàntica de l'element (un botó o una taula deixaven de ser-ho). Tot i que s'ha corregit en les versions recents, no l'apliquis a elements amb funció pròpia i comprova-ho a l'arbre d'accessibilitat.

Web Components i Shadow DOM

Els Web Components (elements personalitzats, com <mi-interruptor>) són la manera estàndard de crear components reutilitzables sense framework, i molts sistemes de disseny els fan servir per donar servei a equips amb tecnologies diferents. Tenen dues particularitats que cal conèixer.

Un element personalitzat no té semàntica

Per al navegador, <mi-boton> és un element genèric, com un span: sense funció, sense focus i sense teclat. Hi ha dues estratègies. La preferible és embolcallar un element natiu dins del seu shadow root (l'arbre intern encapsulat del component):

class MiBoton extends HTMLElement {
  constructor() {
    super();
    const raiz = this.attachShadow({ mode: 'open', delegatesFocus: true });
    raiz.innerHTML = '<button type="button"><slot></slot></button>';
  }
}
customElements.define('mi-boton', MiBoton);

El botó intern aporta funció, focus i teclat, i el seu nom es calcula amb el text que l'autor col·loca dins de <mi-boton>. L'opció delegatesFocus fa que, en enfocar l'amfitrió, el focus passi al primer element enfocable de l'interior.

Quan l'amfitrió ha de ser el mateix control, la interfície ElementInternals permet donar-li funció i estats per defecte sense escriure atributs ARIA que l'autor pugui trepitjar per accident:

class MiInterruptor extends HTMLElement {
  #internals = this.attachInternals();
  constructor() {
    super();
    this.#internals.role = 'switch';
    this.#internals.ariaChecked = 'false';
    this.addEventListener('click', () => this.alternar());
    this.addEventListener('keydown', (e) => {
      if (e.key === ' ') { e.preventDefault(); this.alternar(); }
    });
  }
  connectedCallback() {
    if (!this.hasAttribute('tabindex')) this.tabIndex = 0;
  }
  alternar() {
    this.#internals.ariaChecked = String(this.#internals.ariaChecked !== 'true');
  }
}
customElements.define('mi-interruptor', MiInterruptor);

Amb aquesta estratègia tu ho assumeixes tot: focus, teclat, estats i nom (que haurà d'aportar l'autor amb aria-label o aria-labelledby). És la regla 1 d'ARIA aplicada als Web Components: si existeix un element natiu, fes-lo servir a dins.

Les referències per id no travessen el Shadow DOM

Els atributs que relacionen elements pel seu id (for, aria-labelledby, aria-describedby, aria-controls, aria-activedescendant) només funcionen dins del mateix arbre. Una etiqueta del document no pot apuntar a un <input> que viu dins d'un shadow root, ni un element intern pot apuntar a un missatge d'error de fora.

<!-- Incorrecte: l'input real és dins del shadow root de campo-texto -->
<label for="dni">DNI</label>
<campo-texto id="dni"></campo-texto>   <!-- camp sense nom accessible -->

Solucions, de més a menys recomanable: mantenir etiqueta, control, ajuda i error dins del mateix component (que rep els seus textos per atributs o propietats); convertir-lo en un element associat a formulari (static formAssociated = true juntament amb ElementInternals), que el navegador tracta com a etiquetable i que participa en l'enviament i la validació del formulari; o, com a últim recurs, passar el nom amb aria-label. Hi ha propostes al W3C i als navegadors per permetre referències que travessin aquesta frontera, però la seva compatibilitat encara no permet dependre'n.

Prova-ho tu

Tria un component de camp del teu sistema de disseny o d'una biblioteca coneguda. Inspecciona l'<input> a les eines de desenvolupament i obre la pestanya d'accessibilitat: comprova'n el nom, la descripció i si apareix invalid quan el camp té un error. Si el nom és buit o la descripció no hi arriba, mira a quin element s'estan aplicant els atributs.

Documentar i provar cada component

Un component accessible necessita una fitxa que digui què resol i què ha d'aportar qui el fa servir: propietats obligatòries (etiqueta, títol del diàleg), taula de teclat, atributs ARIA que gestiona el mateix component i exemples d'ús correcte i incorrecte. Prova cada estat (tancat, obert, amb error, deshabilitat) amb les proves automàtiques de la lliçó següent; axe-core analitza també els shadow roots oberts, i els complements d'accessibilitat de catàlegs de components com Storybook executen aquestes regles en cada història. Evita mode: 'closed': oculta l'interior a les eines de prova sense aportar seguretat real.

Punt clau

Un component reutilitzable multiplica el que conté. Fes obligatòries les propietats de les quals depèn el nom accessible, dirigeix els atributs al control real, no hi posis embolcalls que trenquin llistes i taules i, en els Web Components, embolcalla elements natius i mantén junts l'etiqueta i el control dins del mateix arbre.

Resum

  • Dissenya l'API perquè el nom accessible sigui obligatori i la semàntica depengui de l'ús (enllaç o botó, nivell d'encapçalament configurable).
  • Dirigeix id, aria-* i la resta d'atributs al control real (inheritAttrs: false a Vue, resta de propietats a React) i exposa una referència per gestionar el focus.
  • Evita embolcalls entre ul i li o dins de taules: fragments a React, diverses arrels a Vue, selectors d'atribut a Angular.
  • Un element personalitzat no té semàntica: embolcalla un element natiu o fes servir ElementInternals assumint el focus i el teclat.
  • Les referències per id no travessen el Shadow DOM: etiqueta i control en el mateix arbre o element associat a formulari.

T'ha servit aquesta lliçó?

Crea el teu compte gratis i segueix el curs amb tot:

  • El test d'aquesta lliçó (3 preguntes) per comprovar el que has après
  • Exercicis pràctics amb pàgines reals
  • El teu progrés desat: continues on ho vas deixar, des de qualsevol dispositiu
  • Constància del curs amb les hores i certificat gratuït del nivell 3 quan aprovis l'examen

Entres amb el teu compte de Google o només amb el teu correu electrònic. No cal contrasenya.