Usare AJAX senza creare problemi di accessibilità
AJAX (o qualunque chiamata asincrona: fetch
, XHR, librerie) rende le interfacce fluide, ma può diventare un incubo per chi usa tecnologie assistive se non gestiamo stati, focus e annunci. Questa guida spiega come progettare interazioni asincrone che restano accessibili, senza sacrificare la UX.
Principi chiave
- Progressive enhancement: tutto deve funzionare anche senza JavaScript.
- Stati annunciati: comunichiamo caricamento, successo, errore con aree “live”.
- Gestione del focus: dopo l’aggiornamento spostiamo il focus in modo prevedibile.
- Semantica nativa: usare link, pulsanti, liste, titoli. Evitare click su elementi non interattivi.
- Coerenza della tastiera: ogni azione dev’essere possibile da tastiera.
Markup di base (progressive enhancement)
Partiamo da un’interfaccia che funziona anche senza JS. I link e i form inviano la richiesta al server; con JS attivo, intercettiamo l’evento e aggiorniamo la pagina in-place.
<h2 id="results-title" tabindex="-1">Risultati</h2>
<p id="status" role="status" aria-live="polite" aria-atomic="true"></p>
<p id="error" role="alert" hidden></p>
<form action="/ricerca" method="get">
<label for="q">Cerca</label>
<input id="q" name="q" type="search" required>
<button type="submit">Cerca</button>
</form>
<ul id="results" aria-busy="false">
<li>Contenuto iniziale (server-rendered)</li>
</ul>
<button id="load-more">Carica altri risultati</button>
Note: role="status"
con aria-live="polite"
annuncia i messaggi senza interrompere la lettura. role="alert"
è per errori critici. tabindex="-1"
rende il titolo focalizzabile via script, utile dopo un aggiornamento.
Intercettare e migliorare con fetch
Con JavaScript attivo, evitiamo full reload e manteniamo gli aggiornamenti accessibili.
(() => {
const form = document.querySelector('form[action="/ricerca"]');
const results = document.getElementById('results');
const title = document.getElementById('results-title');
const status = document.getElementById('status');
const error = document.getElementById('error');
const loadMore = document.getElementById('load-more');
let nextPage = 2;
let controller;
function announce(msg) {
status.textContent = msg;
}
function showError(msg) {
error.hidden = false;
error.textContent = msg;
}
function clearError() {
error.hidden = true;
error.textContent = '';
}
async function ajaxGet(url) {
// Annulla richieste precedenti per evitare race conditions
if (controller) controller.abort();
controller = new AbortController();
results.setAttribute('aria-busy', 'true');
announce('Caricamento in corso…');
try {
const res = await fetch(url, { signal: controller.signal, headers: { 'Accept': 'application/json' }});
if (!res.ok) throw new Error('Errore di rete (' + res.status + ')');
const data = await res.json();
// Aggiorna la lista in modo non distruttivo
const frag = document.createDocumentFragment();
data.items.forEach(item => {
const li = document.createElement('li');
li.innerHTML = item.html; // Assicurarsi che sia sanificato lato server
frag.appendChild(li);
});
results.appendChild(frag);
clearError();
announce('Caricamento completato: ' + data.items.length + ' elementi aggiunti.');
// Spostiamo il focus sul titolo per annunciare il contesto aggiornato
title.focus();
// Aggiorna la storia per riflettere lo stato (opzionale ma consigliato)
if (data.nextUrl) {
history.replaceState({}, '', data.nextUrl);
}
} catch (e) {
if (e.name !== 'AbortError') {
showError('Impossibile aggiornare i risultati. Riprova più tardi.');
announce('Caricamento non riuscito.');
}
} finally {
results.setAttribute('aria-busy', 'false');
}
}
form.addEventListener('submit', (ev) => {
ev.preventDefault();
const q = new URLSearchParams(new FormData(form));
ajaxGet('/api/ricerca?' + q.toString());
});
loadMore.addEventListener('click', () => {
ajaxGet('/api/ricerca?page=' + nextPage++);
});
})();
Annunciare quantità e posizione
Comunichiamo quante voci sono state aggiunte e, quando utile, la posizione corrente (es. “elemento 21 di 40”). Questo aiuta gli utenti di screen reader a capire il contesto.
function appendItems(listEl, items, totalCount) {
const start = listEl.children.length + 1;
const frag = document.createDocumentFragment();
items.forEach((item, i) => {
const li = document.createElement('li');
const index = start + i;
li.setAttribute('aria-posinset', String(index));
li.setAttribute('aria-setsize', String(totalCount));
li.innerHTML = item.html; // Sanificare lato server
frag.appendChild(li);
});
listEl.appendChild(frag);
}
Indicatori di caricamento e stati
aria-busy
sul contenitore aggiornato indica che è in corso un update.role="status"
per messaggi non intrusivi;role="alert"
per errori.- Non rimuovere il contenuto esistente durante il caricamento; aggiungere un indicatore testuale è sufficiente.
Focus management
- Dopo un aggiornamento significativo, spostare il focus su un titolo o sulla prima voce nuova.
- Usare
tabindex="-1"
sul titolo per poterlo focalizzare via script, senza inserirlo nel tab order normale. - Evitare “focus trap” involontari: non bloccare la tabulazione.
Gestione tastiera
- Usare
<button>
per azioni e<a>
per navigazione. Evitare click su elementi non interattivi. - Se create componenti custom, supportate Enter e Space, e gestite
aria-pressed
o ruoli appropriati.
URL e cronologia
Mantenere l’URL coerente con lo stato aiuta i ritorni e i preferiti, anche per chi usa tecnologie assistive.
// Aggiorna l'URL quando cambia il filtro/ricerca
const params = new URLSearchParams({ q: 'accessibilità', page: '2' });
history.pushState({ q: 'accessibilità', page: 2 }, '', '/ricerca?' + params.toString());
// Ripristino stato quando l'utente usa Back/Forward
window.addEventListener('popstate', (e) => {
// Reidratare UI e rifare la richiesta AJAX coerente con lo stato
});
Errori accessibili
Gli errori devono essere percepibili e riannunciati. Evitare solo colori o toast non vocalizzati.
<p id="error" role="alert" hidden></p>
function showError(msg) {
const error = document.getElementById('error');
error.hidden = false;
error.textContent = msg; // Lo screen reader lo annuncerà
}
Esempio completo minimal
<h2 id="results-title" tabindex="-1">Articoli</h2>
<p id="status" role="status" aria-live="polite" aria-atomic="true"></p>
<p id="error" role="alert" hidden></p>
<form action="/articoli" method="get">
<label for="topic">Tema</label>
<input id="topic" name="topic" type="search" required>
<button type="submit">Filtra</button>
</form>
<ul id="results" aria-busy="false">
<li>Articolo 1 (server)</li>
</ul>
<button id="load-more">Carica altri</button>
<noscript>La ricerca dinamica richiede JavaScript. Puoi usare il form qui sopra per una ricerca completa lato server.</noscript>
Checklist rapida
- Funziona senza JS? (form/link tradizionali)
- Annunci:
role="status"
orole="alert"
presenti e usati. aria-busy
sul contenitore aggiornato.- Focus riportato su titolo o nuovo contenuto.
- Azioni tramite elementi nativi e utilizzabili da tastiera.
- URL aggiornato quando cambia lo stato rilevante.
Seguendo questi accorgimenti, l’uso di AJAX migliora l’esperienza di tutti senza escludere nessuno, mantenendo l’interfaccia veloce, percepibile e prevedibile.