Al llarg d'aquest mòdul hem anat deixant avisos: que posar onClick en un <article> funciona amb el ratolí però no amb el teclat, que el color no pot ser l'únic portador d'informació, que un missatge d'error vermell a sota d'un camp no n'hi ha prou perquè un lector de pantalla el relacioni amb ell. Ha arribat el moment de saldar tots aquests deutes alhora. L'accessibilitat no és una capa que s'afegeix al final ni una llista d'atributs exòtics: és, en un 90 %, escriure l'HTML correcte i no trencar el que el navegador ja et dona fet. En aquesta lliçó revisaràs tot el que s'ha construït al mòdul —els gestors d'esdeveniments, les llistes, el formulari de reserva— i el deixaràs usable amb teclat i amb lector de pantalla, aprenent pel camí quins atributs ARIA existeixen, quan fer-los servir i, més important, quan no fer-los servir.
Contingut
- Per què importa l'accessibilitat
- Les quatre idees de les WCAG
- HTML semàntic primer
- El cas de la targeta prémable
- Etiquetes i missatges d'error en formularis
- Navegació per teclat i gestió del focus
- Regions actives: anunciar el que canvia
- Imatges, icones i el problema del color
- Atributs ARIA d'ús comú
- Com comprovar que funciona
- Per què importa l'accessibilitat
Hi ha tres raons, i totes tres són bones.
Persones. Al voltant del 15 % de la població mundial viu amb alguna discapacitat. No parlem només de ceguesa total: hi ha baixa visió, daltonisme, discapacitat motriu que impedeix fer servir un ratolí amb precisió, discapacitat cognitiva, sordesa. I hi ha discapacitats temporals (un braç escaiolat) i situacionals (fer servir el mòbil a ple sol, amb una mà i amb pressa). A CicloUrbano, algú pot estar consultant el catàleg amb una mà mentre subjecta la bici amb l'altra.
Obligació legal. A la Unió Europea, la Directiva 2016/2102 obliga que els llocs i les aplicacions del sector públic siguin accessibles, i l'Acta Europea d'Accessibilitat estén requisits similars a nombrosos serveis privats, inclòs el comerç electrònic. A Espanya, el Reial Decret 1112/2018 desenvolupa aquestes obligacions. Als Estats Units, l'ADA s'aplica de manera consolidada als llocs web. Traduït: per a molts projectes, no és opcional.
Qualitat general. Una interfície accessible és millor per a tothom. Els subtítols serveixen en un vagó sorollós; un bon contrast ajuda al sol; les dreceres de teclat les agraeix qui introdueix cent reserves al dia; i el marcatge semàntic millora el SEO i facilita les proves automatitzades, com comprovaràs al Mòdul 9.
I hi ha un argument pràctic decisiu: arreglar l'accessibilitat al final costa cinc vegades més que fer-ho bé des del principi. Canviar un <div onClick> per un <button> mentre escrius el component costa zero; fer-ho sis mesos després implica refer estils, revisar la maquetació i tornar a provar-ho tot.
- Les quatre idees de les WCAG
Les WCAG (Web Content Accessibility Guidelines) són l'estàndard internacional. Darrere de les seves desenes de criteris només hi ha quatre principis, que en anglès formen l'acrònim POUR:
| Principi | En una frase | Exemple a CicloUrbano |
|---|---|---|
| Perceptible | La informació s'ha de poder percebre per més d'un sentit o canal | L'estat de la bicicleta es diu amb text, no només amb color |
| Operable | Tot el que es pot fer amb ratolí s'ha de poder fer amb teclat | Es pot recórrer el catàleg i reservar sense tocar el ratolí |
| Comprensible | La interfície ha de ser predictible i els missatges, clars | «La reserva mínima és d'1 hora», no «Valor no vàlid» |
| Robust | El marcatge ha de ser vàlid i interpretable per qualsevol tecnologia d'assistència | HTML semàntic correcte en lloc de <div> amb comportament simulat |
Les WCAG defineixen a més tres nivells de conformitat: A (mínim), AA (el que exigeixen gairebé totes les normatives) i AAA (molt exigent, poc freqüent). L'objectiu raonable de qualsevol projecte és el nivell AA.
- HTML semàntic primer
La primera regla de l'accessibilitat —i la que més problemes evita— és aquesta: fes servir l'element HTML que correspon al que estàs fent.
Un <button> no és un <div> amb estils de botó. Un <button> porta de sèrie:
El que dona un <button> |
El que cal reimplementar en un <div> |
|---|---|
És enfocable amb Tab |
tabIndex={0} |
S'activa amb Enter i Espai |
Un gestor onKeyDown que comprovi ambdues tecles |
| S'anuncia com a «botó» al lector de pantalla | role="button" |
| Té un anell de focus visible per defecte | Estils :focus-visible propis |
Admet disabled, amb la seva semàntica completa |
aria-disabled i bloquejar el gestor a mà |
Envia formularis amb type="submit" |
Res d'equivalent |
Cinc coses que cal reconstruir, i n'hi ha prou d'oblidar-ne una per deixar algú fora.
| Element correcte | Quan usar-lo | Error habitual |
|---|---|---|
<button> |
Executa una acció a la pàgina | <div onClick> |
<a href> |
Navega a una altra adreça | <button> amb navegació per codi |
<nav> |
Bloc de navegació | <div class="menu"> |
<main> |
Contingut principal, un per pàgina | <div id="contingut"> |
<ul> / <ol> / <li> |
Llistes d'elements | <div> repetits |
<table> amb <th> |
Dades tabulars | Graella de <div> |
<form> |
Conjunt de camps que s'envia | <div> amb un botó |
<fieldset> + <legend> |
Grup de camps relacionats (ràdios) | Camps solts |
<h1>–<h6> en ordre |
Estructura del document | Encapçalaments triats per mida |
Sobre els encapçalaments hi ha un matís que es passa per alt: el seu nivell comunica jerarquia, no mida. Qui navega amb lector de pantalla salta d'encapçalament en encapçalament per fer-se un mapa de la pàgina; si tries un <h4> perquè «es veu més petit», aquest mapa queda trencat. La mida es decideix al CSS.
L'estructura de CicloUrbano, amb la semàntica al seu lloc:
// src/App.jsx (estructura)
function App() {
return (
<>
<Capcalera /> {/* <header> amb <nav> a dins */}
<main> {/* el contingut principal, un de sol per pàgina */}
<ResumFlota flota={bicicletes} /> {/* <section> amb <h2> */}
<SelectorTipus /> {/* botons reals */}
<LlistaBicicletes bicicletes={bicicletes} /> {/* <ul> de targetes */}
<FormulariReserva bicicletes={bicicletes} /> {/* <form> */}
</main>
<PeuDePagina /> {/* <footer> */}
</>
);
}Aquests elements creen regions que les tecnologies d'assistència permeten saltar directament: «anar al contingut principal», «anar a la navegació». Amb <div> no existeix aquesta possibilitat.
- El cas de la targeta prémable
A la lliçó 03-01 vam posar onClick a l'<article> de TargetaBicicleta i vam deixar anotat el deute. Anem a saldar-lo.
El problema, en concret: amb un <article onClick>, qui navega amb teclat mai no arriba a la targeta —no és enfocable— i qui fa servir lector de pantalla sent «article», sense cap pista que es pugui prémer.
Solució 1: un botó a dins, i la targeta no és prémable (la millor)
<article className={estils.targeta}>
<h3 className={estils.titol}>
<button type="button" className={estils.enllacTitol} onClick={gestionarClicTargeta}>
{bicicleta.model}
</button>{' '}
<EtiquetaEstat estat={bicicleta.estat} />
</h3>
…
</article>Només el títol és prémable, i és un <button> de veritat. Amb estils es pot fer que sembli un enllaç o un títol normal. Aquesta és l'opció recomanada: zero atributs ARIA, zero gestors de teclat, tot funciona.
Solució 2: la targeta sencera prémable, feta bé
Si el disseny exigeix que tota la targeta sigui una àrea prémable, cal reconstruir a mà el que un botó dona gratis:
function TargetaBicicleta({ bicicleta, alSeleccionar, alReservar, nomEstacio }) {
function gestionarClicTargeta() {
if (alSeleccionar) {
alSeleccionar(bicicleta);
}
}
function gestionarTecla(esdeveniment) {
// Reproduïm el que un <button> fa de sèrie
if (esdeveniment.key === 'Enter' || esdeveniment.key === ' ') {
esdeveniment.preventDefault(); // l'Espai, si no, fa scroll
gestionarClicTargeta();
}
}
return (
<article
className={estils.targeta}
role="button" // s'anunciarà com a botó
tabIndex={0} // entra a l'ordre de tabulació
onClick={gestionarClicTargeta}
onKeyDown={gestionarTecla} // Enter i Espai
aria-label={`Veure detall de ${bicicleta.model}, ${bicicleta.estat}`}
>
…
</article>
);
}/* TargetaBicicleta.module.css — el focus HA de veure's */
.targeta:focus-visible {
outline: 3px solid var(--color-marca);
outline-offset: 2px;
}Quatre afegits per igualar un <button>, i encara queda un problema: si dins de la targeta hi ha un altre botó («Reservar»), acabes amb un control dins d'un altre control, una cosa que cap tecnologia d'assistència interpreta bé. Per això la solució 1 és preferible gairebé sempre.
| Enfocament | Codi extra | Riscos |
|---|---|---|
| Botó a dins de la targeta | Cap | Cap |
Targeta sencera amb role="button" |
role, tabIndex, onKeyDown, aria-label, estils de focus |
Controls imbricats, tecles oblidades |
La regla: si alguna cosa es pot prémer, que sigui un <button> o un <a>. Reconstruir el comportament només es justifica quan no hi ha alternativa.
- Etiquetes i missatges d'error en formularis
Aquí tanquem el que vam prometre a la lliçó anterior. Un formulari accessible necessita tres coses: etiquetes associades, textos d'ajuda vinculats i errors anunciats.
<label htmlFor> associat a l'id
{/* ✔ CORRECTE: htmlFor apunta a l'id del camp */}
<label htmlFor="hores">Durada (hores)</label>
<input id="hores" name="hores" type="number" />
{/* ✔ TAMBÉ CORRECTE: el camp va dins de l'etiqueta */}
<label>
Durada (hores)
<input name="hores" type="number" />
</label>
{/* ✘ MALAMENT: un paràgraf no és una etiqueta */}
<p>Durada (hores)</p>
<input name="hores" type="number" />Recorda de la lliçó 01-04: a JSX s'escriu htmlFor, no for, perquè for és una paraula reservada de JavaScript.
Què es guanya amb l'associació correcta:
- En prémer el text de l'etiqueta, el camp rep el focus. L'àrea prémable creix, la qual cosa ajuda especialment al mòbil i a qui té poca precisió motriu.
- El lector de pantalla anuncia «Durada (hores), quadre d'edició numèric» en arribar al camp. Sense etiqueta associada, només diu «quadre d'edició», i la persona no sap què escriure.
En llistes generades amb map, compte amb els id duplicats: han de ser únics a tota la pàgina. Genera'ls a partir de la dada: id={hores-${bicicleta.id}}.
aria-describedby per a l'ajuda
Quan un camp necessita una explicació addicional, es vincula amb aria-describedby, que accepta un o diversos id separats per espais:
<label htmlFor="hores">Durada (hores)</label>
<input
id="hores"
name="hores"
type="number"
min={1}
max={24}
aria-describedby="hores-ajuda"
/>
<p id="hores-ajuda" className={estils.ajuda}>
Entre 1 i 24 hores. El preu es calcula per hora completa.
</p>El lector de pantalla llegeix primer l'etiqueta i després la descripció. La diferència amb aria-label és important: l'etiqueta diu què és el camp; la descripció aporta detalls addicionals.
aria-invalid i l'error anunciat
I aquí hi ha la peça que tancava la lliçó 03-05: un paràgraf vermell a sota del camp és invisible per a un lector de pantalla si no està associat al camp i no s'anuncia en aparèixer.
<div className={estils.camp}>
<label htmlFor="hores">Durada (hores)</label>
<input
id="hores"
name="hores"
type="number"
min={1}
max={24}
value={dades.hores}
onChange={gestionarCanvi}
onBlur={gestionarBlur}
aria-invalid={mostrarError('hores')}
aria-describedby={
mostrarError('hores') ? 'hores-ajuda hores-error' : 'hores-ajuda'
}
className={classes(estils.control, mostrarError('hores') && estils.invalid)}
/>
<p id="hores-ajuda" className={estils.ajuda}>
Entre 1 i 24 hores.
</p>
{mostrarError('hores') && (
<p id="hores-error" className={estils.error} role="alert">
<span aria-hidden="true">⚠ </span>
{errors.hores}
</p>
)}
</div>Els quatre mecanismes, un a un:
| Mecanisme | Què fa |
|---|---|
aria-invalid={true} |
El lector de pantalla anuncia el camp com a «no vàlid» en arribar-hi |
aria-describedby="… hores-error" |
Associa el missatge al camp: es llegeix en enfocar-lo |
role="alert" |
Converteix el paràgraf en una alerta: s'anuncia tan bon punt apareix, sense esperar al focus |
aria-hidden="true" a la icona |
El símbol «⚠» no es llegeix en veu alta; la seva informació ja és al text |
Fixa't que aria-describedby canvia segons hi hagi error o no, encadenant els dos id. Així el camp conserva la seva ajuda i hi suma l'error quan existeix.
I un detall que ara encaixa: a la lliçó 03-05 vam recomanar no deshabilitar el botó d'enviament. Un dels motius és d'accessibilitat: els elements amb disabled no reben focus, així que qui navega amb teclat pot arribar al final del formulari, no trobar el botó i no entendre per què. És millor un botó actiu que, en prémer-lo, expliqui què falta.
- Navegació per teclat i gestió del focus
Tota la funcionalitat s'ha de poder fer servir només amb el teclat. Aquestes són les tecles que la gent espera:
| Tecla | Comportament esperat |
|---|---|
Tab |
Anar al següent element interactiu |
Majús + Tab |
Anar a l'anterior |
Enter |
Activar botons i enllaços; enviar el formulari des d'un camp de text |
Espai |
Activar botons; marcar i desmarcar caselles |
Fletxes |
Moure's dins d'un grup de ràdios, un select o un menú |
Escape |
Tancar un diàleg, un menú o cancel·lar |
L'ordre de focus
L'ordre de tabulació és l'ordre del marcatge, no el visual. Si amb CSS col·loques un element a l'esquerra però a l'HTML és al final, el focus hi anirà en últim lloc i qui navegui amb teclat es desorientarà. La solució no és tocar tabIndex: és posar el marcatge en l'ordre lògic i maquetar amb order de flexbox o grid només quan no trenqui el sentit.
tabIndex: tres valors i una prohibició
| Valor | Significat | Quan usar-lo |
|---|---|---|
tabIndex={0} |
Enfocable amb Tab, en l'ordre natural |
Un element no interactiu al qual has donat comportament (amb el seu role) |
tabIndex={-1} |
No enfocable amb Tab, però sí per codi amb .focus() |
Destins de focus programàtic: un diàleg que s'obre, un missatge d'error al qual vols saltar |
tabIndex={1} o més |
S'avança a tota la resta | Mai. Destrueix l'ordre de la pàgina i és impossible de mantenir |
Els elements interactius natius —<button>, <a href>, <input>, <select>, <textarea>— ja són enfocables: no els posis tabIndex.
El focus ha de veure's
L'error més estès i més perjudicial:
Eliminar el contorn de focus deixa qui navega amb teclat literalment a les fosques: no sap on és. Si el contorn per defecte no encaixa amb el teu disseny, substitueix-lo, no l'eliminis:
/* ✔ Un focus visible i coherent amb la marca */
.boto:focus-visible {
outline: 3px solid var(--color-marca);
outline-offset: 2px;
border-radius: var(--radi);
}:focus-visible és la pseudoclasse moderna que mostra l'indicador quan el navegador estima que cal —navegació amb teclat— i l'omet després d'un clic de ratolí. És el millor de tots dos mons.
Gestionar tecles: Enter i Escape
A la lliçó 03-01 ja vas afegir Escape al SelectorTipus per tornar al filtre «Totes». El patró general:
function gestionarTecla(esdeveniment) {
if (esdeveniment.key === 'Escape') {
tancar();
return;
}
if (esdeveniment.key === 'Enter') {
confirmar();
}
}Es fa servir sempre esdeveniment.key amb el nom de la tecla ('Enter', 'Escape', 'ArrowRight', ' ' per a l'espai). esdeveniment.keyCode està obsolet des de fa anys: no el facis servir.
- Regions actives: anunciar el que canvia
Quan alguna cosa canvia a la pantalla sense que la persona hagi mogut el focus —«Reserva creada», «3 bicicletes trobades», «Desant…»—, un lector de pantalla no diu res, perquè només llegeix el que hi ha sota el focus. La solució són les regions actives (live regions).
import { useState } from 'react';
function App() {
const [reserves, setReserves] = useState([]);
const [avis, setAvis] = useState('');
function gestionarCrearReserva(reserva) {
setReserves((anteriors) => [...anteriors, reserva]);
setAvis(`Reserva ${reserva.id} creada per a ${reserva.hores} hores.`);
}
return (
<main>
<FormulariReserva bicicletes={bicicletes} alCrearReserva={gestionarCrearReserva} />
{/* La regió existeix SEMPRE al marcatge, encara que estigui buida */}
<p className="avis-viu" role="status" aria-live="polite">
{avis}
</p>
</main>
);
}El detall que gairebé tothom es salta: la regió ha d'existir al DOM des del principi, encara que estigui buida. Si l'element amb aria-live apareix alhora que el seu contingut, molts lectors de pantalla no l'anuncien, perquè no estaven observant aquell node. Renderitza sempre el contenidor i canvia només el seu text.
| Valor | Comportament | Per a què |
|---|---|---|
aria-live="polite" |
Espera que la persona acabi el que està fent | Confirmacions, recomptes de resultats, «desat» |
aria-live="assertive" |
Interromp immediatament | Errors greus, sessió a punt de caducar. Fes-lo servir amb moderació |
role="status" |
Equival a aria-live="polite" |
Drecera semàntica habitual |
role="alert" |
Equival a aria-live="assertive" |
Missatges d'error, com els de l'apartat 5 |
A CicloUrbano hi ha tres llocs on una regió activa aporta de veritat:
- Confirmació de reserva creada, amb
role="status". - Recompte de resultats del catàleg en canviar el filtre: «S'han trobat 3 bicicletes.»
- Missatges d'error del formulari, amb
role="alert".
- Imatges, icones i el problema del color
Text alternatiu
Tot <img> necessita un atribut alt. La pregunta clau és: si aquesta imatge no es carregués, quin text transmetria la mateixa informació?
{/* Imatge informativa: alt descriu el contingut rellevant */}
<img src="/img/bici-urbana.webp" alt="Bicicleta Urbana Clàssica aparcada a Plaça Major" />
{/* Imatge purament decorativa: alt BUIT, mai absent */}
<img src="/img/adorno.svg" alt="" />
{/* ✘ MALAMENT: sense alt, el lector de pantalla llegirà el nom del fitxer */}
<img src="/img/bici-urbana.webp" />alt="" i l'absència d'alt no són el mateix: el primer diu «aquesta imatge no aporta informació, ignora-la»; el segon deixa el lector de pantalla endevinant, i el més habitual és que acabi llegint la URL en veu alta.
I no comencis el text amb «Imatge de…»: el lector ja anuncia que és una imatge.
Icones
Les icones que són només decoració s'han d'ocultar; les que són la informació necessiten una alternativa textual:
{/* Icona decorativa al costat d'un text que ja ho diu tot */}
<button type="button">
<span aria-hidden="true">🔒</span> Bloquejar bicicleta
</button>
{/* Botó NOMÉS amb icona: necessita nom accessible */}
<button type="button" aria-label="Bloquejar bicicleta">
<span aria-hidden="true">🔒</span>
</button>Sense l'aria-label, el segon botó s'anuncia com a «botó», sense més. Un formulari ple de botons així és inutilitzable.
No transmetre informació només amb color
Aquest és el criteri 1.4.1 de les WCAG, i afecta directament EtiquetaEstat. Aproximadament el 8 % dels homes té alguna forma de daltonisme; per a molts d'ells, el verd de «disponible» i el vermell de «manteniment» són pràcticament el mateix to.
La bona notícia és que el nostre component ja ho feia bé des de la lliçó 03-02: mostra el text de l'estat a més del color. El deixem rodó:
// src/components/EtiquetaEstat.jsx
import { classes } from '../utilitats/classes.js';
import estils from './EtiquetaEstat.module.css';
const ESTATS = {
disponible: { text: 'Disponible', simbol: '●', clau: 'disponible' },
alquilada: { text: 'Llogada', simbol: '◐', clau: 'alquilada' },
mantenimiento: { text: 'Al taller', simbol: '✕', clau: 'mantenimiento' }
};
const DESCONEGUT = { text: 'Estat desconegut', simbol: '?', clau: 'desconegut' };
/**
* Distintiu de l'estat d'una bicicleta.
* La informació es transmet per TRES canals: color, símbol i text.
* Props:
* - estat (cadena, opcional, per defecte 'disponible')
*/
function EtiquetaEstat({ estat = 'disponible' }) {
const dades = ESTATS[estat] ?? DESCONEGUT;
return (
<span className={classes(estils.etiqueta, estils[dades.clau])}>
{/* El símbol és redundant per a qui veu el text: s'oculta al lector */}
<span aria-hidden="true">{dades.simbol} </span>
{dades.text}
</span>
);
}
export default EtiquetaEstat;Tres canals independents: color per a qui el distingeix, símbol per a qui no, i text per a tothom, inclòs el lector de pantalla.
Contrast
Les WCAG nivell AA exigeixen un contrast mínim de 4,5:1 per al text normal i 3:1 per al text gran (a partir de 18,66 px en negreta o 24 px normal). El verd de marca de CicloUrbano, #12805c, sobre blanc arriba aproximadament a 4,8:1, així que compleix. Qualsevol comprovador de contrast en línia o el panell d'accessibilitat del navegador et dona la dada en un segon.
- Atributs ARIA d'ús comú
ARIA (Accessible Rich Internet Applications) és un conjunt d'atributs que afegeixen semàntica quan l'HTML no hi arriba. Són un complement, no un substitut.
| Atribut | Què fa | Exemple |
|---|---|---|
aria-label |
Dona un nom accessible quan no hi ha text visible | <button aria-label="Tancar">✕</button> |
aria-labelledby |
Pren el nom d'un altre element pel seu id |
<section aria-labelledby="titol-cataleg"> |
aria-describedby |
Associa una descripció o un missatge d'error | Camp + text d'ajuda |
aria-live |
Anuncia els canvis de contingut d'aquella regió | Confirmació de reserva |
aria-invalid |
Marca un camp com a no vàlid | Camp amb error de validació |
aria-hidden |
Oculta un element a les tecnologies d'assistència | Icones decoratives |
aria-expanded |
Indica si un desplegable està obert | Botó que obre un menú |
aria-current |
Assenyala l'element actual d'un conjunt | aria-current="page" a l'enllaç actiu del menú |
aria-disabled |
Indica deshabilitat sense treure el focus | Botó que explica per què no es pot prémer |
role |
Canvia el paper semàntic de l'element | role="button", role="alert" |
La regla d'or: no ARIA és millor que mal ARIA
És la primera de les cinc regles oficials de l'ús d'ARIA, i significa exactament el que diu: un atribut ARIA mal posat és pitjor que no posar-lo, perquè menteix a la tecnologia d'assistència i aquesta es creu la mentida.
{/* ✘ MALAMENT: ARIA innecessari sobre un element que ja ho fa */}
<button role="button" aria-label="Reservar">Reservar</button>
{/* ✔ BÉ: l'HTML ja diu tot el necessari */}
<button>Reservar</button>
{/* ✘ PITJOR: l'etiqueta ARIA contradiu el text visible */}
<button aria-label="Cancel·lar">Reservar</button>Aquest últim cas és especialment perjudicial: qui veu la pantalla llegeix «Reservar» i qui l'escolta sent «Cancel·lar». I qui fa servir control per veu dirà «prémer Reservar» i no passarà res, perquè per al sistema aquest botó es diu «Cancel·lar».
Les altres quatre regles, resumides:
- Fes servir l'element HTML natiu abans que ARIA.
- No canviïs la semàntica nativa:
<h2 role="button">és un disbarat. - Tot control ARIA ha de ser usable amb teclat.
- No posis
aria-hidden="true"en alguna cosa enfocable: crearies un element invisible per al lector però accessible ambTab.
- Com comprovar que funciona
Prova manual amb Tab: dos minuts, la meitat dels problemes
Guarda el ratolí i recorre la pantalla només amb el teclat. Comprova:
- Puc arribar a tot? Cada botó, enllaç i camp ha de ser accessible.
- Veig on soc? L'indicador de focus ha de ser visible en tot moment.
- L'ordre té sentit? Ha de seguir l'ordre visual de lectura.
- Puc activar-ho tot amb
EnteroEspai? - Em quedo atrapat? Si el focus entra en alguna cosa i no en pot sortir amb
Tab, hi ha una fallada greu.
A CicloUrbano el recorregut correcte és aquest:
flowchart LR
A["Saltar al contingut<br/>(visible en enfocar)"] --> B["Enllaços de la capçalera<br/>Catàleg · Estacions · Les meves reserves"]
B --> C["Botons del SelectorTipus<br/>Totes · Urbanes · Elèctriques · De càrrega"]
C --> D["Botó «Reservar»<br/>de cada targeta, en ordre"]
D --> E["Camps del formulari<br/>bicicleta → data → hores → condicions"]
E --> F["Botó «Crear reserva»"]
F --> G["Enllaços del peu"]
eslint-plugin-jsx-a11y
Detecta en temps d'escriptura una bona part dels errors d'aquesta lliçó:
Avisa, entre altres coses, d'imatges sense alt, d'elements no interactius amb gestors de clic, d'etiquetes sense camp associat, de tabIndex positius i d'atributs ARIA no vàlids. En projectes creats amb les plantilles de React sol venir ja configurat.
Auditoria amb el navegador
Les eines de desenvolupament porten dues utilitats imprescindibles:
- Lighthouse (pestanya del mateix nom a Chrome i Edge): executa una auditoria automàtica i puntua l'accessibilitat, amb la llista de problemes detectats i com corregir-los.
- Arbre d'accessibilitat (dins de l'inspector d'elements): mostra com veu la pàgina una tecnologia d'assistència —el nom, el rol i l'estat de cada element—. És la manera més directa de comprovar si el teu
aria-labelfa efecte.
També existeixen extensions especialitzades com axe DevTools o WAVE, que donen informes més detallats.
Advertència important: les eines automàtiques detecten aproximadament entre el 30 % i el 40 % dels problemes reals. Poden dir-te que falta un alt, però no si el text que has escrit és útil; poden verificar el contrast, però no si l'ordre de focus té sentit. La prova amb Tab i, quan sigui possible, amb un lector de pantalla real (NVDA a Windows, VoiceOver a macOS i iOS, TalkBack a Android) continua sent insubstituïble.
Existeix a més la possibilitat d'automatitzar comprovacions d'accessibilitat a les proves, amb eines com jest-axe integrades a la bateria de proves del projecte. És una pràctica excel·lent i es menciona aquí perquè sàpigues que existeix; el terreny de les proves és el Mòdul 9.
Errors Comuns i Consells
<div onClick>en lloc de<button>. No és enfocable, no respon al teclat i no s'anuncia com a control. És, de llarg, la fallada d'accessibilitat més comuna a React.outline: nonesense substitut. Deixa qui navega amb teclat sense saber on és. Fes servir:focus-visibleamb un contorn propi.tabIndexpositiu. Trenca l'ordre de tabulació de tota la pàgina. Només0i-1.- Posar
tabIndexa elements que ja són enfocables. Un<button tabIndex={0}>és redundant i confús. - Etiquetes sense associar. Un
<p>a sobre del camp no és una etiqueta.<label htmlFor>amb l'idcorresponent, i recorda que a JSX éshtmlFor, nofor. idduplicats en llistes generades ambmap. Trenquen l'associació etiqueta-camp. Genera'ls a partir de l'id de la dada.- Missatges d'error només visuals. Sense
role="alert"no s'anuncien en aparèixer, i sensearia-describedbyno es llegeixen en enfocar el camp. - Una regió
aria-liveque apareix juntament amb el seu contingut. No s'anuncia. El contenidor ha d'estar sempre al DOM. aria-live="assertive"per a tot. Interromp constantment i acaba sent insuportable. Reserva el mode assertiu per al que és urgent.- Imatges sense
alt, o amb «imatge de…». Sensealtes llegeix la URL; el prefix és redundant perquè el lector ja anuncia que és una imatge. - Botons amb només una icona i sense nom accessible. S'anuncien com a «botó» a seques.
aria-labelobligatori. - Transmetre informació només amb color. Afegeix sempre text o un símbol.
- Encapçalaments triats per la seva mida. Trenquen el mapa de la pàgina. El nivell és jerarquia; la mida es decideix al CSS.
- ARIA redundant o contradictori.
<button role="button">sobra; unaaria-labeldiferent del text visible és una fallada greu. No ARIA és millor que mal ARIA. - Consell: prova amb
Tabcada vegada que acabis un component. Dos minuts que estalvien auditories senceres. - Consell: instal·la
eslint-plugin-jsx-a11yel primer dia del projecte. Corregeix mentre escrius en lloc de al final. - Consell: escriu el marcatge en l'ordre lògic de lectura i fes servir CSS per col·locar-lo, no
tabIndexper reordenar-lo.
Exercicis
Exercici 1
Aquest component té sis problemes d'accessibilitat. Identifica'ls, explica a qui afecta cadascun i escriu la versió corregida.
function TargetaEstacioInteractiva({ estacio, ocupades, alSeleccionar }) {
return (
<div className="targeta" onClick={() => alSeleccionar(estacio)}>
<img src={`/img/estacions/${estacio.id}.webp`} />
<p className="titol-gran">{estacio.nom}</p>
<span className={ocupades === estacio.places ? 'punt-vermell' : 'punt-verd'} />
<div role="button" onClick={() => alSeleccionar(estacio)}>
Veure detall
</div>
<button aria-label="Tancar">Veure al mapa</button>
</div>
);
}Exercici 2
Pren el camp «Bicicleta» del FormulariReserva de la lliçó 03-05 i fes-lo completament accessible. Ha d'incloure:
- Etiqueta correctament associada.
- Un text d'ajuda vinculat: «Només es mostren les bicicletes disponibles ara mateix.»
aria-invalidquan el camp tingui un error visible.- El missatge d'error associat i anunciat en aparèixer.
- Un
aria-describedbyque combini ajuda i error quan tots dos existeixin.
Escriu també les classes CSS necessàries perquè l'estat d'error es distingeixi sense dependre del color.
Exercici 3
Dissenya el recorregut de teclat complet de la pantalla principal de CicloUrbano i descriu-lo pas a pas. Després respon:
- On col·locaries una regió
aria-livei què anunciaria en cada cas? - Què hauria de passar en prémer
Escapea cada zona de la pantalla? - Si la llista de bicicletes tingués cent targetes amb un botó cadascuna, quin problema de navegació apareixeria i com el resoldries sense trencar res?
Solucions
Solució 1.
Els sis problemes:
| Problema | A qui afecta |
|---|---|
<div onClick> com a targeta prémable |
Qui navega amb teclat no hi arriba; el lector de pantalla no anuncia que sigui interactiva |
<img> sense alt |
El lector de pantalla llegeix la URL del fitxer |
<p className="titol-gran"> en lloc d'un encapçalament |
Qui navega saltant entre encapçalaments no troba l'estació |
| El punt de color com a única senyal d'ocupació | Qui no distingeix el vermell del verd no rep la informació |
<div role="button"> sense tabIndex ni gestor de teclat |
S'anuncia com a botó però no es pot abastar ni activar amb teclat: pitjor que no posar el role |
aria-label="Tancar" que contradiu el text «Veure al mapa» |
Qui escolta sent una cosa diferent del que es veu; el control per veu no funciona |
I al CSS, outline: none sense substitut elimina l'indicador de focus.
// src/components/TargetaEstacioInteractiva.jsx
import { classes } from '../utilitats/classes.js';
import estils from './TargetaEstacioInteractiva.module.css';
/**
* Targeta d'una estació de CicloUrbano amb accions.
* Props:
* - estacio (objecte, obligatori) { id, nom, barri, places }
* - ocupades (número, opcional, per defecte 0)
* - alSeleccionar, alVerMapa (funcions, opcionals)
*/
function TargetaEstacioInteractiva({ estacio, ocupades = 0, alSeleccionar, alVerMapa }) {
const completa = ocupades === estacio.places;
const lliures = estacio.places - ocupades;
return (
<article className={estils.targeta}>
<img
src={`/img/estacions/${estacio.id}.webp`}
alt={`Estació ${estacio.nom}, al barri ${estacio.barri}`}
/>
{/* Encapçalament real: nivell 3 dins de la secció d'estacions */}
<h3 className={estils.titol}>{estacio.nom}</h3>
{/* Color + símbol + TEXT: tres canals */}
<p className={classes(estils.ocupacio, completa ? estils.completa : estils.lliure)}>
<span aria-hidden="true">{completa ? '✕' : '●'} </span>
{completa ? 'Estació completa' : `${lliures} places lliures`}
</p>
{/* Botons REALS: enfocables, activables amb Enter i Espai */}
<button type="button" onClick={() => alSeleccionar(estacio)}>
Veure detall de {estacio.nom}
</button>
<button type="button" onClick={() => alVerMapa(estacio)}>
Veure al mapa
</button>
</article>
);
}
export default TargetaEstacioInteractiva;/* src/components/TargetaEstacioInteractiva.module.css */
.targeta {
background: var(--color-superficie);
border: 1px solid var(--color-vora);
border-radius: var(--radi);
padding: var(--espai);
}
/* El focus se substitueix, mai s'elimina */
.targeta button:focus-visible {
outline: 3px solid var(--color-marca);
outline-offset: 2px;
}
.lliure { color: var(--color-marca); }
.completa { color: var(--color-manteniment); font-weight: 700; }La decisió de fons: s'ha eliminat l'onClick de la targeta sencera. L'àrea prémable són dos botons de veritat amb text descriptiu, així que no calen role, tabIndex, onKeyDown ni aria-label.
Solució 2.
<div className={estils.camp}>
<label htmlFor="bicicletaId">Bicicleta</label>
<select
id="bicicletaId"
name="bicicletaId"
required
value={dades.bicicletaId}
onChange={gestionarCanvi}
onBlur={gestionarBlur}
aria-invalid={mostrarError('bicicletaId')}
aria-describedby={
mostrarError('bicicletaId')
? 'bicicleta-ajuda bicicleta-error'
: 'bicicleta-ajuda'
}
className={classes(estils.control, mostrarError('bicicletaId') && estils.invalid)}
>
<option value="">— Tria una bicicleta —</option>
{disponibles.map((bicicleta) => (
<option key={bicicleta.id} value={bicicleta.id}>
{bicicleta.model} · {bicicleta.preuHora.toFixed(2).replace('.', ',')} €/h
</option>
))}
</select>
<p id="bicicleta-ajuda" className={estils.ajuda}>
Només es mostren les bicicletes disponibles ara mateix.
</p>
{mostrarError('bicicletaId') && (
<p id="bicicleta-error" className={estils.error} role="alert">
<span aria-hidden="true">⚠ </span>
{errors.bicicletaId}
</p>
)}
</div>/* src/components/FormulariReserva.module.css */
.control {
border: 1px solid var(--color-vora);
border-radius: var(--radi);
padding: 0.5rem;
width: 100%;
}
.control:focus-visible {
outline: 3px solid var(--color-marca);
outline-offset: 2px;
}
/* Estat d'error: color + GRUIX de vora + fons, no només color */
.invalid {
border-color: var(--color-manteniment);
border-width: 2px;
background-color: #fdf2f2;
}
.ajuda {
font-size: 0.85rem;
color: var(--color-text);
opacity: 0.8;
margin: 0.25rem 0 0;
}
/* El missatge porta símbol i text: el color és el tercer canal, no l'únic */
.error {
font-size: 0.9rem;
font-weight: 700;
color: var(--color-manteniment);
margin: 0.25rem 0 0;
}Les tres senyals de l'estat d'error, independents entre si: la vora més gruixuda i el fons (perceptibles sense distingir el color), el símbol ⚠ i el text del missatge. I per al lector de pantalla, aria-invalid l'anuncia com a camp no vàlid, aria-describedby li associa ajuda i error, i role="alert" fa que el missatge es llegeixi tan bon punt apareix.
Solució 3.
Recorregut de teclat proposat:
- Enllaç «Saltar al contingut», ocult fins a rebre focus, que porta directament al
<main>. És el primer element de la pàgina i evita haver de recórrer tota la navegació a cada visita. - Capcalera: enllaços «Catàleg», «Estacions», «Les meves reserves», amb
aria-current="page"a l'actiu. SelectorTipus: els quatre botons de filtre, en ordre.LlistaBicicletes: per cada targeta, el seu botó «Reservar».FormulariReserva: bicicleta → data → hores → casella de condicions → botó «Crear reserva».- Peu de pàgina: els seus enllaços.
1. Regions aria-live: dues, totes dues presents al DOM des del primer render.
| Regió | Rol | Què anuncia |
|---|---|---|
| Recompte del catàleg | role="status" |
«S'han trobat 3 bicicletes» en canviar el filtre |
| Confirmació de reserva | role="status" |
«Reserva creada per a 2 hores» després d'un enviament correcte |
Els errors del formulari no necessiten una regió compartida: cada missatge ja porta el seu role="alert".
2. Comportament de Escape:
| Zona | Acció d'Escape |
|---|---|
SelectorTipus |
Tornar al filtre «Totes» (ja implementat a 03-01) |
| Un camp del formulari | Restaurar el valor anterior del camp, o no fer res. Mai buidar el formulari sencer: seria una pèrdua de dades irreversible i sorprenent |
| Un diàleg o menú desplegable | Tancar-lo i tornar el focus a l'element que l'ha obert |
3. El problema de les cent targetes: per arribar al formulari caldria prémer Tab cent vegades. És una barrera real, i les solucions que no valen són tabIndex positius (trenquen tot l'ordre) o treure els botons del recorregut amb tabIndex={-1} (els faria inabastables). El que sí que funciona:
- Enllaços de salt abans i després de la llista: «Saltar la llista de bicicletes» / «Tornar al filtre», visibles només en rebre el focus.
- Encapçalaments i regions ben marcats, perquè qui fa servir lector de pantalla salti per estructura en lloc de per tabulació.
- Paginació o càrrega progressiva, que a més millora el rendiment i beneficia tothom.
- Un
<section aria-labelledby>per a la llista, de manera que s'anunciï com a regió identificable i es pugui saltar d'una vegada.
Conclusió
Amb aquesta lliçó tanques el Mòdul 3 i saldes tots els deutes que va anar deixant pel camí. Saps per què l'accessibilitat importa —persones, obligació legal i qualitat general— i coneixes els quatre principis de les WCAG: perceptible, operable, comprensible i robust. Sobretot, has interioritzat la regla que resol la major part dels problemes abans que existeixin: fes servir l'element HTML correcte. Un <button> porta de sèrie el focus, el teclat, el rol i l'estat; un <div> amb onClick obliga a reconstruir cinc coses i n'hi ha prou d'oblidar-ne una per deixar algú fora.
Ho has aplicat a tot el que s'ha construït al mòdul: la targeta prémable de la lliçó 03-01 passa a tenir botons de veritat; el FormulariReserva associa cada <label htmlFor> al seu id, vincula l'ajuda amb aria-describedby, marca els camps amb aria-invalid i anuncia els missatges amb role="alert" —tancant exactament el que va quedar pendent a 03-05—; EtiquetaEstat transmet l'estat per tres canals independents, color, símbol i text, per no deixar fora qui no distingeix els colors; i una regió aria-live avisa del que canvia sense que ningú mogui el focus. Saps gestionar l'ordre de tabulació, quan fer servir tabIndex={0} i tabIndex={-1} i per què els valors positius estan prohibits, i que l'indicador de focus se substitueix amb :focus-visible però mai no s'elimina. Coneixes els atributs ARIA habituals i la regla que els governa: no ARIA és millor que mal ARIA. I saps comprovar-ho: amb Tab en dos minuts, amb eslint-plugin-jsx-a11y mentre escrius i amb Lighthouse i l'arbre d'accessibilitat del navegador, recordant que les eines automàtiques només detecten un terç dels problemes reals.
Fent balanç del mòdul complet: CicloUrbano ha deixat de mirar-se i ha començat a respondre. Els esdeveniments connecten la interfície amb la lògica, amb gestors ben anomenats, esdeveniments sintètics i propagació sota control. El renderitzat condicional fa que cada targeta mostri el que correspon al seu estat i que el catàleg sàpiga què dir quan no hi ha res. Les llistes amb map i claus estables han eliminat la repetició escrita a mà i han tancat el deute de la reconciliació. Els formularis controlats recullen dades amb l'estat com a única font de veritat, la validació els filtra amb regles de negoci de veritat, i l'accessibilitat fa que tot això arribi a qualsevol persona.
Però queda un límit que hem fregat a cada lliçó sense poder-lo travessar. El SelectorTipus guarda el tipus triat en el seu propi estat i avisa el pare, però continua sense filtrar la llista, perquè LlistaBicicletes no pot veure aquest estat. TargetaBicicleta avisa amb alSeleccionar i App es limita a fer un console.log, perquè no hi ha on guardar la bicicleta seleccionada. L'estat és privat de cada component, i això és precisament el que impedeix que les peces treballin juntes. La solució té nom i obre el Mòdul 4: Conceptes Avançats de Components. La propera lliçó és Elevar l'Estat, i amb ella el catàleg de CicloUrbano filtrarà de veritat.
Curs de React
Mòdul 1: Introducció a React
- Què és React?
- Configuració de l'Entorn de Desenvolupament
- Hola Món amb React
- JSX: Extensió de Sintaxi de JavaScript
- Com Renderitza React: Virtual DOM i Reconciliació
Mòdul 2: Components de React
- Entendre els Components
- Components Funcionals vs de Classe
- Props: Passar Dades als Components
- State: Gestió de l'Estat del Component
- Estils en els Components: CSS, Mòduls i Utilitats
Mòdul 3: Treballar amb Esdeveniments
- Gestió d'Esdeveniments a React
- Renderitzat Condicional
- Llistes i Claus
- Formularis i Components Controlats
- Validació de Formularis i Components No Controlats
- Accessibilitat en Components Interactius
Mòdul 4: Conceptes Avançats de Components
- Elevar l'Estat
- Composició vs Herència
- Mètodes del Cicle de Vida de React
- Hooks: Introducció i Ús Bàsic
- Límits d'Error: Capturar Fallades a la Interfície
Mòdul 5: Hooks de React
- Hook useState
- Hook useEffect
- Hook useRef i Accés al DOM
- Hook useContext
- Hook useReducer
- Hooks Personalitzats
Mòdul 6: Enrutament a React
- Introducció a React Router
- Configuració de React Router
- Rutes Imbricades
- Navegació Programàtica
- Rutes Protegides i Control d'Accés
Mòdul 7: Gestió de l'Estat
- Introducció a la Gestió de l'Estat
- API de Context
- Redux: Introducció i Configuració
- Redux: Accions i Reductors
- Redux: Connectar-lo a React
- Estat del Servidor: Peticions, Memòria Cau i Sincronització
Mòdul 8: Optimització del Rendiment
- Tècniques d'Optimització del Rendiment a React
- Memoïtzació amb React.memo
- Hooks useMemo i useCallback
- Divisió de Codi i Càrrega Mandrosa
- Mesurar el Rendiment amb React DevTools Profiler
Mòdul 9: Proves a React
- Introducció a les Proves
- Proves Unitàries amb Jest
- Proves de Components amb React Testing Library
- Proves de Codi Asíncron i Simulació d'APIs
- Proves d'Extrem a Extrem amb Cypress
Mòdul 10: Temes Avançats
- Renderitzat al Servidor (SSR) amb Next.js
- Generació de Llocs Estàtics (SSG) amb Next.js
- Suspense i React Server Components
- TypeScript amb React
- React Native: Creació d'Aplicacions Mòbils
