A 07-01 vas aprendre a anunciar canvis dins d'una pàgina. Ara pugem un nivell: què passa quan tota l'aplicació és una sola pàgina i la "navegació" no recarrega res? El tauler de l'alumne de Cursalia —"Els meus cursos", "Progrés", "Certificats", "Configuració"— és una SPA (Single Page Application): construïda amb un framework de client (React, Vue o Angular, tant se val quin a aquest nivell), carrega un únic HTML i, en "canviar de pàgina", l'enrutador de client reescriu el DOM sense demanar una pàgina nova al servidor. Per a la Sofía o el Hugo, que veuen la pantalla, la transició és fluida i ràpida. Per a la Marta, amb NVDA, és un desastre silenciós: el lector de pantalla no anuncia la pàgina nova i el focus es queda surant on era. En aquesta lliçó resolem aquest problema central i els seus derivats. No ens casarem amb cap framework: els principis són els mateixos a tots.
Contingut
- Per què una SPA trenca l'accessibilitat que el navegador donava de franc
- Les tres reaccions a un canvi de ruta: títol, focus i anunci
- Actualitzar el
document.title - Moure el focus en muntar la vista
- Anunciar la ruta amb un "route announcer" (regió activa d'enrutador)
- Restaurar el focus en tornar enrere i no trencar el botó Enrere
- Skip links i modals enrutats en una SPA
- Càrrega diferida i estats de vista
- Errors comuns i consells
- Exercicis
- Conclusió
- Per què una SPA trenca l'accessibilitat que el navegador donava de franc
En una web tradicional (multipàgina), cada clic en un enllaç provoca una recàrrega completa. Aquesta recàrrega, que sembla un detall tècnic, li regalava a l'usuari de lector de pantalla tres coses de manera automàtica:
| En recarregar una pàgina, el navegador… | …i l'usuari de lector de pantalla obtenia |
|---|---|
Canvia el <title> |
NVDA anuncia el títol de la pàgina nova |
| Reinicia el focus al principi del document | El focus comença en un lloc conegut, a dalt |
| Reconstrueix l'arbre d'accessibilitat sencer | El lector "sap" que és en una pàgina nova |
Una SPA elimina la recàrrega, i amb ella es perden aquestes tres cortesies. L'enrutador de client canvia l'URL amb la History API (pushState), intercanvia uns nodes del DOM… i ja està. Per a la Marta, no ha passat res: NVDA no anuncia el canvi, el focus continua a l'enllaç que va prémer (que pot haver desaparegut del DOM), i no hi ha cap senyal que ara sigui a "Certificats" en lloc de a "Els meus cursos". La nostra feina és retornar a mà el que la recàrrega donava de franc.
flowchart TB
A[L'usuari prem un enllaç del tauler] --> B{Web tradicional<br/>o SPA?}
B -->|Tradicional| C[Recàrrega: títol, focus i<br/>arbre es renoven sols]
B -->|SPA| D[pushState: el DOM canvia<br/>però res no s'anuncia]
D --> E[Hem de fer, per codi:<br/>1 títol · 2 focus · 3 anunci]
- Les tres reaccions a un canvi de ruta: títol, focus i anunci
Cada vegada que l'enrutador de la SPA completa un canvi de ruta, hem de disparar fins a tres reaccions. No sempre les tres alhora, però són el repertori:
- Actualitzar el
document.title— perquè el títol de la pestanya i l'anunci del lector reflecteixin la vista actual. - Moure el focus — a un lloc predictible de la vista nova (l'
<h1>ambtabindex="-1", o una regió), perquè el teclat no es quedi perdut. - Anunciar el canvi — amb una regió activa d'enrutador, per si no movem el focus o volem un missatge explícit de "pàgina carregada".
Hi ha dues estratègies combinables: moure el focus a l'encapçalament (que de passada fa que el lector llegeixi la vista nova) o anunciar amb una regió activa sense moure el focus. Molts equips fan totes dues o trien segons el cas. Vegem-les una a una.
- Actualitzar el
document.title
document.titleÉs el més fàcil i el primer que has d'arreglar. En una recàrrega, el <title> canvia tot sol; en una SPA, el canvies tu en resoldre la ruta:
// En entrar a cada ruta del tauler de l'alumne
function alEntrarARuta(ruta) {
document.title = `${ruta.titol} · Tauler de l'alumne · Cursalia`;
}
// Ex.: "Progrés · Tauler de l'alumne · Cursalia"Per què importa: molts lectors de pantalla anuncien el document.title quan canvia, i és el que l'usuari sent en orientar-se. A més, millora l'historial del navegador, els marcadors i el SEO. Posa el nom de la vista primer: "Progrés · Cursalia" és més útil que "Cursalia · Progrés" quan l'usuari té 20 pestanyes obertes.
- Moure el focus en muntar la vista
La tècnica més recomanada en canviar de ruta és moure el focus a l'encapçalament principal de la vista nova. Li dones a l'<h1> un tabindex="-1" (enfocable per codi, fora de la tabulació natural) i l'enfoques quan la vista acaba de muntar-se:
<!-- Cada vista del tauler arrenca amb el seu h1 preparat per rebre focus -->
<main id="vista">
<h1 tabindex="-1" id="titol-vista">Progrés</h1>
<!-- … contingut de la vista … -->
</main>// Es crida DESPRÉS que el framework hagi pintat la vista nova
function enfocarNovaVista() {
const h1 = document.getElementById('titol-vista');
h1.focus(); // NVDA llegeix "Progrés, encapçalament nivell 1"
// Opcional: portar el scroll a dalt, com faria una recàrrega
window.scrollTo(0, 0);
}En enfocar l'<h1>, passen dues coses bones alhora: la Marta sent el nom de la vista nova (perquè el lector anuncia l'element enfocat) i la Lucía té el focus de teclat en un punt lògic des d'on començar a tabular. És la solució 2-en-1.
Important — moment correcte: enfoca després que el framework hagi renderitzat la vista. A React seria dins d'un
useEffectque depèn de la ruta; a Vue, al hook de muntatge; a Angular, després de la navegació. Si enfoques abans que l'<h1>existeixi, no passa res. Per això aquest patró viu sempre al cicle de vida "vista ja muntada".
- Anunciar la ruta amb un "route announcer" (regió activa d'enrutador)
L'alternativa (o complement) a moure el focus és un route announcer: una única regió activa, present sempre al layout, on escrivim el nom de la vista nova a cada navegació. Reutilitza tot el de 07-01:
<!-- Al layout arrel de la SPA, SEMPRE present, visualment ocult -->
<div id="route-announcer" role="status" aria-live="polite" class="sr-only"></div>/* Ocult a la vista, però disponible per al lector de pantalla */
.sr-only {
position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}// Enganxat a l'esdeveniment "navegació completada" de l'enrutador
function anunciarRuta(titol) {
document.getElementById('route-announcer').textContent =
`${titol}, pàgina carregada`;
}Fixa't que la regió està sempre al DOM des de l'arrencada (no la creem a cada navegació): és exactament la regla d'or de 07-01. Canviem només el seu textContent, i com que és role="status" (polite), NVDA anuncia "Progrés, pàgina carregada" sense interrompre.
Focus a l'<h1> o route announcer? Guia pràctica:
| Situació | Recomanació |
|---|---|
| Navegació normal entre vistes del tauler | Moure el focus a l'<h1> (2-en-1) |
| Vols un missatge explícit "pàgina carregada" sense robar el focus | Route announcer |
Vista molt complexa on el focus a l'h1 desorienta |
Route announcer + no moure focus |
| Màxima robustesa | Totes dues: focus a l'h1 i títol actualitzat |
- Restaurar el focus en tornar enrere i no trencar el botó Enrere
El botó Enrere del navegador és sagrat. Una SPA ben feta usa la History API de manera que Enrere/Endavant funcionin com l'usuari espera. Dues regles:
- No trenquis el botó Enrere. Si interceptes la navegació, usa
history.pushState/popstatecorrectament perquè Enrere torni a la vista anterior, no fora de l'aplicació. Un botó Enrere que "es salta" passos o treu l'usuari de Cursalia és desorientador per a tothom, i crític per a qui depèn del teclat. - Gestiona el focus en tornar. Quan l'usuari prem Enrere i torna, per exemple, del detall d'un curs a la llista, l'ideal és retornar el focus a l'element des del qual va sortir (la targeta del curs que va obrir), no al principi. Això requereix recordar la posició:
// En navegar CAP A un detall, recordem qui ens hi va portar
let origenNavegacio = null;
enllacCurs.addEventListener('click', () => {
origenNavegacio = enllacCurs; // desem el disparador
});
// En tornar (popstate) a la llista, restaurem el focus si podem
window.addEventListener('popstate', () => {
// després de renderitzar la vista de destinació:
if (origenNavegacio && document.contains(origenNavegacio)) {
origenNavegacio.focus();
} else {
document.getElementById('titol-vista')?.focus(); // pla B: l'h1
}
});És el mateix principi de "retornar el focus al disparador" que vas aprendre amb els modals al Mòdul 5, ara aplicat a la navegació sencera.
- Skip links i modals enrutats en una SPA
Skip links que continuen funcionant
L'enllaç "Salta al contingut principal" (Mòdul 2) apunta a #contingut-principal. En una SPA, com que el <main> es reconstrueix a cada vista, assegura't que l'àncora de destinació continua existint després de cada navegació i que el skip link mou el focus de debò (no només el scroll):
<a href="#contingut-principal" class="skip-link">Salta al contingut principal</a>
<!-- … -->
<main id="contingut-principal" tabindex="-1">…</main>El tabindex="-1" al <main> garanteix que, en activar el skip link, el focus aterri allà (alguns navegadors no enfoquen destinacions sense tabindex). Si el teu framework recrea el <main>, comprova que conserva aquest id i aquest tabindex.
Modals enrutats
Un patró habitual a les SPA és que un modal tingui la seva pròpia ruta (per exemple /panel/curs/42/matricula obre el modal de matrícula sobre la llista). Combina el del Mòdul 5 amb el d'aquest mòdul:
- En obrir el modal (entrar a la seva ruta): mou el focus dins del diàleg i activa el focus trap (Mòdul 5).
- En tancar-lo (sortir de la ruta, també amb el botó Enrere): retorna el focus a l'element que el va obrir.
- Vigila que el botó Enrere tanqui el modal en lloc de treure l'usuari de la vista: si el modal és una ruta,
popstatel'ha de tancar, no navegar fora.
- Càrrega diferida i estats de vista
Les SPA carreguen trossos de codi i dades sota demanda (lazy loading, code splitting). Mentre una vista carrega, hi ha un lapse en què no hi ha contingut. Aplica el que vas aprendre a 07-01:
- Marca la regió que s'està poblant amb
aria-busy="true"i posa-la afalseen acabar. - Mostra un estat de càrrega anunciat ("S'està carregant el teu progrés…") en una regió
status, no un spinner mut. - Quan arribin les dades, decideix si mous el focus a l'
<h1>de la vista ja poblada (evita enfocar contingut que encara és un esquelet).
async function muntarVistaProgres() {
const main = document.getElementById('contingut-principal');
main.setAttribute('aria-busy', 'true');
document.getElementById('route-announcer').textContent = `S'està carregant el progrés…`;
const dades = await api.progres(); // arriba asíncron
pintarProgres(dades);
main.setAttribute('aria-busy', 'false');
document.getElementById('titol-vista').focus(); // ara sí, focus a la vista a punt
}Errors comuns i consells
- No canviar el
document.titleen navegar. L'usuari de lector de pantalla no té manera de saber en quina vista és. És l'arreglo més barat i de més impacte. - Deixar el focus a l'enllaç que ja no existeix. Després de la navegació, el node de l'enllaç pot haver estat eliminat del DOM; el focus cau al
<body>i la Marta es perd. Mou el focus a l'<h1>de la vista nova. - Crear el route announcer a cada navegació. Igual que a 07-01: la regió ha d'estar al layout des de l'arrencada, present i buida. Només en canvies el text.
- Enfocar abans que la vista estigui muntada. Enfocar un
<h1>que encara no existeix no fa res. Fes-ho al cicle de vida "ja renderitzat" del framework. - Trencar el botó Enrere. Si Enrere treu l'usuari de la SPA o salta passos, has trencat una expectativa universal. Usa la History API amb compte.
- Posar
aria-liveal contenidor de la vista sencera. Si marques tot el<main>com a regió activa, cada canvi de vista intenta llegir tota la pàgina. Anuncia només un missatge breu en una regió dedicada. - Consell: encapsula "canviar títol + moure focus + anunciar" en una sola funció que el teu enrutador cridi a cada navegació. Així és impossible oblidar un pas, i tota la SPA es comporta igual.
Exercicis
Exercici 1. La Marta navega al tauler de l'alumne de "Els meus cursos" a "Certificats". Visualment tot canvia, però ella no percep res: NVDA continua callat i el seu focus és a l'enllaç que va prémer. Enumera les tres reaccions que hauria de disparar l'enrutador en completar el canvi de ruta i explica què resol cadascuna.
Exercici 2. Un company implementa l'anunci de ruta així. Per què la Marta no sent res i com ho arregles?
function anunciar(titol) {
const div = document.createElement('div');
div.setAttribute('role', 'status');
div.textContent = `${titol}, pàgina carregada`;
document.body.appendChild(div);
}Exercici 3. A la SPA, el modal de matrícula té la seva pròpia ruta. Descriu què ha de passar amb el focus en obrir-lo i en tancar-lo, i què ha de fer el botó Enrere del navegador amb el modal obert.
Solucions
Solució 1. (1) Actualitzar el document.title a alguna cosa com "Certificats · Tauler de l'alumne · Cursalia": dona al lector i a la pestanya la identitat de la vista nova. (2) Moure el focus a l'<h1 tabindex="-1">Certificats</h1> de la vista acabada de muntar: fa que NVDA llegeixi el nom de la vista i situa el focus de teclat en un punt lògic. (3) Anunciar el canvi al route announcer (role="status") per si no es mou el focus o es vol un "pàgina carregada" explícit. Amb les tres, es reconstrueix el que una recàrrega donava de franc: identitat, focus i avís.
Solució 2. No se sent res perquè la regió role="status" es crea i s'emplena en el mateix instant (i s'insereix al DOM ja amb text). El lector no la tenia registrada com a regió activa. La solució és tenir una única regió al layout, present des de l'arrencada i buida, i limitar-se a canviar-ne el text:
function anunciar(titol) {
document.getElementById('route-announcer').textContent = `${titol}, pàgina carregada`;
}Solució 3. En obrir el modal (entrar a la seva ruta): moure el focus dins del diàleg (al títol o al primer camp) i activar el focus trap del Mòdul 5. En tancar (sortir de la ruta): retornar el focus a l'element que el va obrir, desat com a disparador. El botó Enrere, amb el modal obert, ha de tancar el modal (equivalent a sortir de la seva ruta amb popstate), no navegar fora de la vista subjacent ni treure l'usuari de Cursalia. Així el modal es comporta de manera predictible tant amb el ratolí com amb el teclat i l'historial.
Conclusió
Una SPA elimina la recàrrega de pàgina i, amb ella, tres cortesies que el navegador donava de franc als lectors de pantalla: títol nou, focus reiniciat i arbre reconstruït. Ara saps retornar-les a mà a cada canvi de ruta —actualitzar el document.title, moure el focus a l'<h1> i/o anunciar amb un route announcer— a més de restaurar el focus en tornar enrere, no trencar el botó Enrere, mantenir els skip links i els modals enrutats, i gestionar la càrrega diferida. El fil amb 07-01 és directe: les mateixes regions actives i la mateixa gestió del focus, ara a escala d'aplicació sencera.
Amb el tauler de l'alumne ja accessible com a SPA, queda un front que hem anat fregant des del principi: Cursalia no està en un sol idioma, sinó en es/ca/en, i el multilingüisme té les seves pròpies trampes d'accessibilitat. Sap el lector de pantalla en quin idioma llegir cada text? Què passa amb la direcció del text, les dates, els subtítols per idioma o els alt traduïts? Això és el que abordem a la lliçó següent, 07-03: Internacionalització i localització.
Curs d'Accessibilitat Web
Mòdul 1: Introducció a l'Accessibilitat Web
- Què és l'Accessibilitat Web?
- La Importància de l'Accessibilitat Web
- Visió General de les Lleis i Normes d'Accessibilitat
- Introducció a les WCAG
Mòdul 2: Comprensió de les Discapacitats i les Tecnologies d'Assistència
- Tipus de Discapacitats
- Visió General de les Tecnologies d'Assistència
- Com Utilitzen la Web les Persones amb Discapacitat
Mòdul 3: Principis del Disseny Accessible
- Perceptible: Fer el Contingut Disponible als Sentits
- Operable: Interfície d'Usuari i Navegació
- Comprensible: Informació i Operació
- Robust: Compatibilitat amb Tecnologies Actuals i Futures
Mòdul 4: Implementació de l'Accessibilitat en HTML i CSS
- HTML Semàntic
- Formularis Accessibles
- Ús de Rols i Propietats ARIA
- Contrast de Color i Redimensionament del Text
Mòdul 5: Accessibilitat en JavaScript i Multimèdia
- Creació de Widgets de JavaScript Accessibles
- Accessibilitat del Teclat
- Contingut de Vídeo i Àudio Accessible
- Proporcionar Alternatives de Text per a les Imatges
Mòdul 6: Proves i Avaluació de l'Accessibilitat
- Tècniques de Proves Manuals
- Eines de Proves Automatitzades
- Proves d'Usuari amb Tecnologies d'Assistència
- Interpretació d'Informes d'Accessibilitat
