Usare AJAX senza creare problemi di accessibilità

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

  1. Funziona senza JS? (form/link tradizionali)
  2. Annunci: role="status" o role="alert" presenti e usati.
  3. aria-busy sul contenitore aggiornato.
  4. Focus riportato su titolo o nuovo contenuto.
  5. Azioni tramite elementi nativi e utilizzabili da tastiera.
  6. 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.