El Mòdul 4 ens va deixar una base estàtica: HTML semàntic, formularis amb label, ARIA com a semàntica declarativa i estils amb focus visible. Recorda que el desplegable del filtre del catàleg de Cursalia va quedar "declarat però sense comportament": tenia el seu role, el seu nom accessible i els seus estats ARIA preparats, però en prémer-lo no passava res. En aquesta lliçó li donem vida amb JavaScript. Aprendràs a construir components interactius (widgets) que no només funcionen amb ratolí, sinó que exposen el seu estat a les tecnologies d'assistència i gestionen el focus correctament. La regla d'or que repetirem fins a l'avorriment: cada vegada que canvia el DOM, ha de canviar l'estat ARIA alhora.
Contingut
- Què és un widget i per què ARIA sense JavaScript no basta
- La ARIA Authoring Practices Guide (APG) com a catàleg de patrons
- Patró Disclosure: el filtre del catàleg cobra vida
- Patró Acordió: temari i FAQ de Cursalia
- Patró Tabs: pestanyes del panell de l'alumne
- Patró Diàleg Modal: la matrícula
- El cicle "DOM + estat ARIA alhora"
- Errors comuns i consells
- Exercicis
- Conclusió
- Què és un widget i per què ARIA sense JavaScript no basta
Un widget és un component d'interfície compost que el navegador no ofereix de manera nativa: un acordió, unes pestanyes, un combobox, un diàleg modal. Al Mòdul 4 vam aprendre que ARIA descriu el rol, el nom i l'estat d'un element, però ARIA no afegeix comportament. Un aria-expanded="false" no fa que res es col·lapsi; només anuncia que alguna cosa està col·lapsada. Som nosaltres, amb JavaScript, qui hem de:
- Reaccionar a la interacció de l'usuari (clic, teclat).
- Canviar el DOM (mostrar/amagar, seleccionar).
- Sincronitzar l'estat ARIA perquè la Marta (amb NVDA) senti el canvi.
- Gestionar el focus perquè la Lucía (que navega amb teclat) no es perdi.
Sense aquesta sincronització, tenim un component que "funciona" visualment però menteix a les tecnologies d'assistència. Un botó que desplega un panell però manté aria-expanded="false" és pitjor que no tenir ARIA: promet una informació falsa.
- La ARIA Authoring Practices Guide (APG) com a catàleg de patrons
No has d'inventar com s'ha de comportar un acordió. La ARIA Authoring Practices Guide (APG) és la guia oficial del W3C que documenta, per a cada patró de widget, tres coses:
| Què documenta l'APG | Exemple (patró Tabs) |
|---|---|
| Rols, estats i propietats ARIA | role="tablist", role="tab", role="tabpanel", aria-selected, aria-controls |
| Interacció de teclat esperada | Fletxes per canviar de pestanya, Home/End, Tab surt del grup |
| Gestió del focus | Roving tabindex sobre les pestanyes |
L'APG és el teu contracte de comportament. Quan un usuari de lector de pantalla troba un role="tablist", el seu programari li anuncia "grup de pestanyes" i espera poder-se moure amb les fletxes. Si tu implementes una altra cosa, trenques aquesta expectativa. Per això en aquesta lliçó seguim l'APG al peu de la lletra.
Divisió de treball en aquest mòdul. Aquí (05-01) construïm els patrons i gestionem els estats ARIA dinàmics i el focus de cada component. Les convencions de tecles exhaustives i les mecàniques de focus (tabindex, roving tabindex, focus trap, ordre del focus) es detallen a 05-02 (Accessibilitat del Teclat). Quan aquí diguem "gestiona les fletxes" o "atrapa el focus", la tècnica concreta és a 05-02. El contingut dinàmic de les SPA (enrutament, anuncis de canvi de pàgina) és del Mòdul 7.
- Patró Disclosure: el filtre del catàleg cobra vida
Un disclosure és el patró més simple: un botó que mostra o amaga una regió. És exactament el que necessita el desplegable del filtre de Cursalia. Reprenguem l'HTML que vam deixar preparat al Mòdul 4:
<!-- Estàtic, tal com va quedar al Mòdul 4: declarat però sense comportament -->
<button id="btn-filtre" aria-expanded="false" aria-controls="panell-filtre">
Filtra cursos
</button>
<div id="panell-filtre" hidden>
<fieldset>
<legend>Categoria</legend>
<label><input type="checkbox" name="cat" value="dev"> Desenvolupament</label>
<label><input type="checkbox" name="cat" value="design"> Disseny</label>
</fieldset>
</div>Fixa't en tres detalls que ja estaven a punt: és un <button> real (no un <div>), té aria-expanded i té aria-controls apuntant al panell per id. Ara el JavaScript:
const btn = document.getElementById('btn-filtre');
const panell = document.getElementById('panell-filtre');
btn.addEventListener('click', () => {
// 1. Llegim l'estat ACTUAL des de l'ARIA (font de veritat)
const obert = btn.getAttribute('aria-expanded') === 'true';
// 2. Canviem el DOM i l'estat ARIA ALHORA
btn.setAttribute('aria-expanded', String(!obert));
panell.hidden = obert; // si estava obert, ara l'amaguem
// 3. Gestió del focus: en OBRIR, portem el focus al primer control del panell
if (!obert) {
panell.querySelector('input, button, a')?.focus();
}
});Per què panell.hidden i no display:none per CSS? L'atribut hidden retira el contingut de l'arbre d'accessibilitat I de l'ordre de tabulació alhora. La Marta no el sent i la Lucía no pot tabular a un checkbox invisible. És la manera més robusta d'amagar.
Sobre la gestió del focus aquí convé matisar: en un disclosure senzill (menú de navegació, "llegeix més") no és obligatori moure el focus en obrir; moltes implementacions deixen el focus al botó. Però en un panell de filtre amb formulari, moure el focus al primer control millora molt l'experiència de teclat. En tancar, si el focus estava dins del panell, cal retornar-lo al botó per no deixar el focus orfe:
// En tancar per Escape o en tornar a prémer, retornem el focus al disparador
function tancarPanell() {
btn.setAttribute('aria-expanded', 'false');
panell.hidden = true;
btn.focus(); // el focus torna a un lloc predictible
}
- Patró Acordió: temari i FAQ de Cursalia
L'acordió és una pila de disclosures: diverses capçaleres que expandeixen/col·lapsen la seva secció. És el patró del temari i del bloc de FAQ. La clau APG és que cada capçalera és un <button> embolcallat en un encapçalament (<h3>):
<h3>
<button aria-expanded="false" aria-controls="sec-1" id="cap-1">
Mòdul 1: Introducció
</button>
</h3>
<div id="sec-1" role="region" aria-labelledby="cap-1" hidden>
<p>Contingut del mòdul 1…</p>
</div>El <h3> permet que la Marta salti de secció en secció amb la tecla d'encapçalaments de NVDA. El role="region" + aria-labelledby dona nom a cada panell. La lògica JS és idèntica al disclosure, generalitzada a diversos ítems:
document.querySelectorAll('.acordio button').forEach((cap) => {
cap.addEventListener('click', () => {
const obert = cap.getAttribute('aria-expanded') === 'true';
cap.setAttribute('aria-expanded', String(!obert));
document.getElementById(cap.getAttribute('aria-controls')).hidden = obert;
// En un acordió NO movem el focus al panell: l'usuari continua llegint
// en ordre. El focus es queda a la capçalera (comportament APG).
});
});Decisió de disseny important: permetre diversos panells oberts alhora o només un? L'APG permet les dues opcions. Per a un temari, deixa'n diversos oberts (l'usuari compara mòduls); per a un FAQ llarg, tancar els altres en obrir-ne un redueix el soroll. Si tanques els altres, recorda actualitzar el seu aria-expanded a false — és l'error més típic.
- Patró Tabs: pestanyes del panell de l'alumne
Les pestanyes del panell de l'alumne (per exemple: "Els meus cursos", "Progrés", "Certificats") fan servir tres rols coordinats. Només el panell actiu és visible; els altres porten hidden.
<div role="tablist" aria-label="Panell de l'alumne">
<button role="tab" id="t1" aria-selected="true" aria-controls="p1">Els meus cursos</button>
<button role="tab" id="t2" aria-selected="false" aria-controls="p2" tabindex="-1">Progrés</button>
</div>
<div role="tabpanel" id="p1" aria-labelledby="t1" tabindex="0">…</div>
<div role="tabpanel" id="p2" aria-labelledby="t2" tabindex="0" hidden>…</div>Observa el tabindex="-1" a la pestanya no seleccionada: és el roving tabindex, la tècnica que fa que Tab entri al grup per una sola parada i les fletxes moguin entre pestanyes. Aquesta mecànica s'explica en detall a 05-02; aquí ens centrem en l'estat ARIA dinàmic:
const tabs = document.querySelectorAll('[role="tab"]');
function activar(tabSel) {
tabs.forEach((tab) => {
const seleccionada = tab === tabSel;
// Estat ARIA + DOM sincronitzats per a CADA pestanya
tab.setAttribute('aria-selected', String(seleccionada));
tab.tabIndex = seleccionada ? 0 : -1; // roving tabindex
document.getElementById(tab.getAttribute('aria-controls')).hidden = !seleccionada;
});
tabSel.focus(); // el focus acompanya la pestanya activa
}
tabs.forEach((tab) => tab.addEventListener('click', () => activar(tab)));
// La gestió de fletxes (ArrowLeft/Right, Home, End) s'implementa segons 05-02.El patró mental és el mateix de sempre: recorrem tots els elements del grup i, per a cadascun, posem el seu aria-selected, el seu tabindex i la visibilitat del seu panell coherents entre si.
- Patró Diàleg Modal: la matrícula
El diàleg modal de matrícula és el patró més exigent perquè ha d'atrapar l'usuari a dins mentre està obert. L'ideal avui és l'element natiu <dialog> amb showModal(), que aporta molt de franc (focus inicial, Esc per tancar, fons inert). Quan no el puguis fer servir, replicaràs el seu comportament amb ARIA:
<div role="dialog" aria-modal="true" aria-labelledby="titol-modal" id="modal" hidden>
<h2 id="titol-modal">Matrícula al curs</h2>
<form>…</form>
<button id="tancar-modal">Cancel·la</button>
</div>role="dialog" + aria-modal="true" diu al lector de pantalla que la resta de la pàgina està inert: la Marta no hauria de poder sortir del diàleg per error. aria-labelledby dona nom al diàleg amb el seu títol. L'esquelet JS d'obertura i tancament:
let disparador = null; // recordem QUI va obrir el modal
function obrirModal(botoQueLObrio) {
disparador = botoQueLObrio;
modal.hidden = false;
// Focus inicial: al primer camp o al títol del diàleg
modal.querySelector('input, button, [tabindex]')?.focus();
// Activar el FOCUS TRAP (tècnica detallada a 05-02)
}
function tancarModal() {
modal.hidden = true;
// Gestió del focus IMPRESCINDIBLE: retornar el focus a qui va obrir
disparador?.focus();
}Dues coses crítiques que defineixen un modal accessible:
- Focus trap: mentre el modal està obert,
TabiShift+Tabhan de circular dins del diàleg, sense escapar-se al contingut de fons. La tècnica completa (detectar el primer i l'últim element focusable i redirigir el focus) s'implementa a 05-02. - Retornar el focus en tancar: guardem l'element que va obrir el modal (
disparador) i li retornem el focus en tancar. Si no, la Lucía tanca el modal i el seu focus cau al principi de la pàgina: desorientació total.
- El cicle "DOM + estat ARIA alhora"
Tots els patrons anteriors comparteixen el mateix esquelet. Interioritza'l com un cicle únic:
flowchart LR A[Interacció<br/>clic o tecla] --> B[Llegir estat actual<br/>des de l'ARIA] B --> C[Canviar el DOM<br/>mostrar/amagar/seleccionar] C --> D[Sincronitzar ARIA<br/>expanded/selected/hidden] D --> E[Gestionar el focus<br/>moure o retornar]
La fase 4 (focus) i la fase 3 (ARIA) són les que gairebé tothom oblida. Un component que només fa la fase "canviar el DOM" és un component trencat per a l'accessibilitat encara que es vegi perfecte.
Errors Comuns i Consells
- Fer servir
<div>ambonclicken lloc de<button>. Undivno és focusable ni operable per teclat; hauries d'afegirtabindex,role="button"i gestionarEnter/Espaia mà. Fes servir sempre l'element natiu: t'ho dona tot de franc. - Canviar el DOM i oblidar l'ARIA. El cas clàssic: mostrar el panell però deixar
aria-expanded="false". La Marta sent "col·lapsat" mentre veu (qui veu) el panell obert. - No retornar el focus en tancar un modal o panell. Deixa el focus orfe al principi del document. Guarda sempre el disparador.
- Amagar amb
visibility:hiddenmal aplicada o ambopacity:0.opacity:0deixa l'element focusable i a l'arbre d'accessibilitat: la Lucía tabula a botons invisibles. Fes servirhiddenodisplay:none. - Reinventar les tecles. No inventis que l'acordió s'obre amb la fletxa dreta si l'APG diu que va amb
Enter/Espai. Segueix el patró: la consistència és accessibilitat. - Consell: abans de programar un widget, busca el seu patró a l'APG i copia la seva taula de rols i tecles. És la teva especificació.
Exercicis
Exercici 1. El següent disclosure canvia el panell però té un bug d'accessibilitat. Identifica'l i corregeix-lo.
Exercici 2. Escriu la funció activar(tab) d'un grup de pestanyes que, a més de mostrar el panell correcte, deixi tots els estats ARIA coherents. Enumera quins atributs has de tocar a cada pestanya.
Exercici 3. Un modal de matrícula s'obre amb focus correcte però, en prémer "Cancel·la", el focus salta a l'inici de la pàgina. Què falta i com ho arregles?
Solucions
Solució 1. El bug és que mai s'actualitza aria-expanded. El panell es mostra/amaga visualment, però el lector de pantalla continua anunciant l'estat antic. Correcció sincronitzant tots dos:
btn.addEventListener('click', () => {
const obert = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!obert));
panell.hidden = obert;
});Solució 2. Per cada pestanya cal sincronitzar tres coses: aria-selected (true només a l'activa), tabindex (0 a l'activa, -1 a la resta: roving tabindex) i el hidden del seu panell associat. A més, moure el focus a la pestanya activada.
function activar(tabSel) {
document.querySelectorAll('[role="tab"]').forEach((tab) => {
const sel = tab === tabSel;
tab.setAttribute('aria-selected', String(sel));
tab.tabIndex = sel ? 0 : -1;
document.getElementById(tab.getAttribute('aria-controls')).hidden = !sel;
});
tabSel.focus();
}Solució 3. Falta guardar el disparador i retornar-li el focus en tancar. S'emmagatzema el botó que va obrir el modal en una variable i, a tancarModal(), es crida a disparador.focus(). Sense això, en amagar el modal el focus cau al <body> i d'allà a l'inici del document.
Conclusió
Ja saps construir els widgets clau de Cursalia seguint la ARIA Authoring Practices Guide: disclosure (el filtre que vam deixar pendent al Mòdul 4), acordió, tabs i diàleg modal. La idea central és el cicle "canvia el DOM i l'estat ARIA alhora, i gestiona el focus". Tanmateix, hem anat deixant pendents les mecàniques de teclat: quines tecles mouen entre pestanyes, com funciona el roving tabindex, com s'implementa exactament el focus trap del modal i com evitar les trampes de teclat. Tot això és la base sobre la qual s'apoia qualsevol widget, i és justament el que veurem a la lliçó següent, 05-02: Accessibilitat del Teclat, amb la Lucía i la Marta com a protagonistes.
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
