Digitální dostupnost v PA: povinnost, nikoli možnost

Přístupnost webových stránek a mobilních aplikací italské veřejné správy je řídí Zákon z 9. ledna 2004, n. 4 (Law Stanca), aktualizováno o Legislativní vyhláška 106/2018 která implementovala směrnici EU 2016/2102 o přístupnosti webových stránek orgány veřejného sektoru. Referenční technické pokyny jsou: WCAG 2.1 (Pokyny pro usnadnění přístupu k webovému obsahu) na úrovni souladu AA.

Od 23. září 2020 musí všechny PA zveřejnit a Prohlášení o přístupnosti na svých webových stránkách, které jsou každoročně aktualizovány. AgID monitoruje shodu prostřednictvím automatizovaného systému MAUVE++ (vyvinutý s CNR), který analyzuje kód HTML a CSS podle 31 z 50 kritérií úspěšné WCAG 2.1 úrovně A a AA.

Dal 28. června 2025 vstupuje v platnost dnem Evropský akt o přístupnosti (EAA), Směrnice EU 2019/882, která rozšiřuje povinnost přístupnosti také na digitální produkty a služby soukromé osoby: elektronický obchod, bankovnictví, dopravní služby, média. V případě PA posiluje SZÚ stávající závazky.

Co se naučíte

  • 4 principy WCAG (POUR) a nejkritičtější kritéria úspěchu PA
  • Sémantické HTML: nepostradatelný základ pro jakoukoli dostupnou implementaci
  • ARIA (Accessible Rich Internet Applications): kdy ji použít a kdy se jí vyhnout
  • Navigace pomocí klávesnice: správa zaostření, přeskakování odkazů a viditelné režimy zaostření
  • Čtečky obrazovky: Jak VoiceOver, NVDA a JAWS interpretují DOM
  • Automatizované testování: axe-core, MAUVE++, Lighthouse, Pa11y
  • Ruční testování: scénáře se skutečnými čtečkami obrazovky
  • Prohlášení o přístupnosti AGID: struktura a publikace

4 principy WCAG: POUR

WCAG 2.1 je založeno na 4 základních principech, známých pod zkratkou NALIJTE:

Princip Popis Klíčová kritéria (AA) Běžné chyby PA
Vnímatelný Informace musí být prezentovatelné vnímatelnými způsoby Alternativní text, kontrast 4,5:1, titulky, zvukové popisy Obrázky bez alt, soubory PDF nejsou přístupné, videa bez titulků
Operativní Vše musí být použitelné z klávesnice a v dostatečném čase Přístupná klávesnice, přeskakování odkazů, žádný obsah vyvolávající záchvaty Nabídka pouze pro myš, časové limity jsou příliš krátké, zaostření není vidět
Srozumitelný Obsah a rozhraní musí být srozumitelné Jazyk stránky, popisky formulářů, prevence chyb Formulář bez štítku, nepopisné chyby, chybějící jazyk
Robustní Obsah musí být interpretovatelný současnými i budoucími asistenčními technologiemi Platná analýza HTML, název/role/hodnota pro všechny komponenty Neplatné HTML, špatně použitá ARIA, vlastní komponenty bez rolí

Sémantické HTML: základ všeho

Prvním pravidlem přístupnosti webu je použití správného sémantického HTML. Před aplikací ARIA ujistěte se, že nativní prvky HTML již komunikují správnou strukturu a roli čtečky obrazovky. Sémantické HTML je ze své podstaty přístupné; problém nastává při jejich použití generické prvky (<div>, <span>) pro funkčnost, kterou HTML nativní zvládá lépe.

<!-- SBAGLIATO: struttura non semantica -->
<div class="header">
  <div class="nav">
    <div class="nav-item" onclick="navigate('/home')">Home</div>
    <div class="nav-item" onclick="navigate('/servizi')">Servizi</div>
  </div>
</div>
<div class="main-content">
  <div class="article-title">Titolo del Servizio</div>
  <div class="content">...</div>
</div>

<!-- CORRETTO: HTML semantico accessibile -->
<header>
  <nav aria-label="Navigazione principale">
    <ul>
      <li><a href="/home">Home</a></li>
      <li><a href="/servizi">Servizi</a></li>
    </ul>
  </nav>
</header>
<main id="main-content">  <!-- id per skip link -->
  <article>
    <h1>Titolo del Servizio</h1>
    <p>...</p>
  </article>
</main>

<!-- Elementi landmark per screen reader -->
<!-- header, nav, main, aside, footer sono landmark ARIA nativi -->
<!-- Lo screen reader NVDA li annuncia come: "banner", "navigation", "main", etc. -->

Přístupné formuláře: kritický bod pro služby PA

On-line formuláře jsou srdcem interakce mezi občany a PA: žádost o dokumenty, registrace, prohlášení, platby. Jsou zde také nejčastější chyby v přístupnosti častější a účinnější. Nepřístupná forma účinně vylučuje občany se zdravotním postižením od přístupu k veřejným službám.

<!-- Form accessibile per servizi PA -->
<!-- Criteri WCAG coinvolti: 1.3.1, 1.3.2, 2.4.6, 3.3.1, 3.3.2, 3.3.4 -->

<form novalidate aria-labelledby="form-title" aria-describedby="form-desc">

  <h2 id="form-title">Richiesta Certificato di Residenza</h2>
  <p id="form-desc">
    Compila tutti i campi obbligatori (contrassegnati con *).
    Il certificato verrà inviato all'indirizzo email indicato entro 3 giorni lavorativi.
  </p>

  <!-- Skip link per saltare al contenuto principale -->
  <a href="#form-start" class="skip-link">Vai al form</a>

  <fieldset id="form-start">
    <legend>Dati Anagrafici</legend>

    <!-- Campo con label esplicita e messaggio errore associato -->
    <div class="form-field">
      <label for="codice-fiscale">
        Codice Fiscale
        <span aria-hidden="true" class="required-marker">*</span>
        <span class="sr-only">(obbligatorio)</span>
      </label>
      <input
        type="text"
        id="codice-fiscale"
        name="codice-fiscale"
        required
        aria-required="true"
        aria-describedby="cf-hint cf-error"
        autocomplete="on"
        pattern="[A-Z0-9]{16}"
        maxlength="16"
      >
      <span id="cf-hint" class="field-hint">
        16 caratteri alfanumerici (es. RSSMRA85T10A562S)
      </span>
      <span id="cf-error" class="field-error" role="alert" aria-live="polite">
        <!-- Popolato dinamicamente in caso di errore -->
      </span>
    </div>

    <!-- Select accessibile -->
    <div class="form-field">
      <label for="tipo-documento">
        Tipo di documento richiesto
        <span aria-hidden="true">*</span>
        <span class="sr-only">(obbligatorio)</span>
      </label>
      <select id="tipo-documento" name="tipo-documento" required aria-required="true">
        <option value="">Seleziona...</option>
        <option value="residenza">Certificato di Residenza</option>
        <option value="stato-famiglia">Certificato di Stato di Famiglia</option>
        <option value="nascita">Certificato di Nascita</option>
      </select>
    </div>

  </fieldset>

  <!-- Gruppo radio button con fieldset/legend -->
  <fieldset>
    <legend>Modalità di consegna</legend>
    <div class="radio-group">
      <input type="radio" id="consegna-email" name="consegna" value="email" checked>
      <label for="consegna-email">Via email (PDF)</label>
    </div>
    <div class="radio-group">
      <input type="radio" id="consegna-sportello" name="consegna" value="sportello">
      <label for="consegna-sportello">Ritiro allo sportello</label>
    </div>
  </fieldset>

  <button type="submit">Invia Richiesta</button>

</form>

<style>
/* Skip link visibile solo al focus - pattern essenziale per navigazione tastiera */
.skip-link {
  position: absolute;
  top: -40px;
  left: 0;
  padding: 8px;
  background: #000;
  color: #fff;
  z-index: 9999;
  transition: top 0.2s;
}
.skip-link:focus {
  top: 0;
}

/* Screen reader only: testo visibile solo allo screen reader */
.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;
}

/* Focus visibile: WCAG 2.4.7 (AA) e 2.4.11 (AA in WCAG 2.2) */
:focus-visible {
  outline: 3px solid #0066cc;
  outline-offset: 2px;
}
</style>

AIR: Kdy ho použít (a kdy se mu vyhnout)

ARIA (Accessible Rich Internet Applications) rozšiřuje HTML o atributy, které komunikují role, stavy a vlastnosti na asistenční technologie. První pravidlo ARIA říká: "Pokud můžete použít nativní prvek HTML s sémantika a chování, které požadujete, jsou již zabudovány, místo abyste znovu použili prvek a přidali roli ARIA, státu nebo majetku, aby byl přístupný, pak tak udělejte".

VZDUCH se stává nepostradatelným pro vlastní widgety které nativní HTML nepokrývá: modály, akordeon, tabulátory, posuvník, combobox s automatickým doplňováním. V těchto případech je nástrojem ARIA správně komunikovat sémantiku čtečce obrazovky.

<!-- Esempio: Modal accessibile per PA (conferma azione critica) -->
<!-- Criteri WCAG: 2.4.3 Focus Order, 4.1.2 Name/Role/Value -->

<!-- Trigger -->
<button
  type="button"
  id="open-modal-btn"
  aria-haspopup="dialog"
  aria-expanded="false"
  aria-controls="confirm-modal"
>
  Conferma Eliminazione Pratica
</button>

<!-- Modal -->
<div
  id="confirm-modal"
  role="dialog"
  aria-modal="true"
  aria-labelledby="modal-title"
  aria-describedby="modal-desc"
  aria-hidden="true"
  tabindex="-1"
>
  <div class="modal-inner">
    <h2 id="modal-title">Conferma Eliminazione</h2>
    <p id="modal-desc">
      Sei sicuro di voler eliminare la pratica #PR-2024-001?
      Questa azione è irreversibile.
    </p>

    <div class="modal-actions">
      <button type="button" id="modal-cancel">Annulla</button>
      <button type="button" id="modal-confirm" class="btn-danger">
        Elimina definitivamente
      </button>
    </div>
  </div>
</div>

<!-- JavaScript per focus trap nel modal -->
<script>
class AccessibleModal {
  constructor(modalId, triggerId) {
    this.modal = document.getElementById(modalId);
    this.trigger = document.getElementById(triggerId);
    this.focusableSelectors = [
      'button:not([disabled])',
      '[href]',
      'input:not([disabled])',
      'select:not([disabled])',
      'textarea:not([disabled])',
      '[tabindex]:not([tabindex="-1"])'
    ].join(',');
  }

  open() {
    this.modal.removeAttribute('aria-hidden');
    this.trigger.setAttribute('aria-expanded', 'true');
    this.lastFocus = document.activeElement;

    // Focus sul primo elemento focusabile del modal
    const firstFocusable = this.modal.querySelectorAll(this.focusableSelectors)[0];
    if (firstFocusable) firstFocusable.focus();

    // Trap focus nel modal
    this.modal.addEventListener('keydown', this._trapFocus.bind(this));

    // Chiudi con Escape
    document.addEventListener('keydown', this._handleEscape.bind(this));
  }

  close() {
    this.modal.setAttribute('aria-hidden', 'true');
    this.trigger.setAttribute('aria-expanded', 'false');
    this.modal.removeEventListener('keydown', this._trapFocus.bind(this));
    document.removeEventListener('keydown', this._handleEscape.bind(this));

    // Restituisce il focus al trigger (WCAG 2.4.3)
    this.lastFocus?.focus();
  }

  _trapFocus(e) {
    if (e.key !== 'Tab') return;
    const focusable = [...this.modal.querySelectorAll(this.focusableSelectors)];
    const first = focusable[0];
    const last = focusable[focusable.length - 1];

    if (e.shiftKey && document.activeElement === first) {
      e.preventDefault();
      last.focus();
    } else if (!e.shiftKey && document.activeElement === last) {
      e.preventDefault();
      first.focus();
    }
  }

  _handleEscape(e) {
    if (e.key === 'Escape') this.close();
  }
}
</script>

Kontrast a typografie: Perceivable Criteria

Kritérium WCAG 2.1 1.4.3 Kontrast (minimální) na úrovni AA vyžaduje kontrastní poměr alespoň 4,5:1 pro prostý text e 3:1 pro velký text (18pt nebo 14pt tučné) a komponenty uživatelského rozhraní (ohraničení vstupu, informační ikony). Kritérium 1.4.6 Kontrast (vylepšený) na úrovni AAA přináší tyto prahové hodnoty na 7:1 a 4,5:1.

/* CSS: sistema di colori accessibile per siti PA italiani */
/* Ispirato al design system di Designers Italia (UI Kit Italia) */

:root {
  /* Palette primaria - tutti i contrasti calcolati e verificati */

  /* Bianco su blu scuro: 8.59:1 (supera AAA) */
  --color-primary: #0066cc;
  --color-primary-dark: #004d99;
  --color-on-primary: #ffffff;

  /* Testo scuro su bianco: 14.47:1 (supera AAA) */
  --color-text-primary: #17324d;
  --color-text-secondary: #5b6f82; /* 4.58:1 su bianco - passa AA */
  --color-background: #ffffff;

  /* Errori: rosso scuro con contrasto 5.12:1 su bianco */
  --color-error: #c32f00;
  --color-error-bg: #fef0ec; /* sfondo chiaro per messaggi errore */

  /* Attenzione: sfondo giallo scuro, testo scuro */
  --color-warning: #6b4700;  /* 7.03:1 su sfondo warning-bg */
  --color-warning-bg: #fff6d6;

  /* Successo */
  --color-success: #1a6600;  /* 6.56:1 su sfondo success-bg */
  --color-success-bg: #e5f7e0;

  /* Dimensioni minime testo */
  --font-size-base: 1rem;    /* 16px - minimo per body */
  --font-size-small: 0.875rem; /* 14px - mai sotto questa soglia */
  --line-height-base: 1.6;   /* WCAG raccomanda >= 1.5 */
  --letter-spacing-base: 0.02em; /* Migliora leggibilità */
}

/* Focus visibile: non togliere mai l'outline! */
/* WCAG 2.4.7 (AA): ogni elemento deve avere uno stato di focus visibile */
:focus-visible {
  outline: 3px solid var(--color-primary);
  outline-offset: 2px;
  /* NON: outline: none !important; --- questo viola WCAG */
}

/* Testo leggibile: non solo contrasto, anche dimensioni */
body {
  font-size: var(--font-size-base);
  line-height: var(--line-height-base);
  color: var(--color-text-primary);
  background: var(--color-background);
}

/* Rispetta le preferenze di movimento ridotto */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

/* Rispetta la preferenza per testo grande del sistema operativo */
@media (prefers-contrast: high) {
  :root {
    --color-primary: #003d7a;
    --color-text-secondary: #2c3e50;
  }
}

Automatizované testování: axe-core, MAUVE++ a Lighthouse

Automatizované testování přístupnosti může detekovat přibližně 30-40 % chyb WCAG, podle odhadů Deque Systems. Zbývajících 60–70 % vyžaduje ruční testování se skutečnými čtečkami obrazovky. Nicméně, automatizace je nezbytná pro zachycení regresí v cyklu CI/CD.

# Testing accessibilità con axe-core in Python (Playwright)
# Integrazione in CI/CD per catturare regressioni

import asyncio
from playwright.async_api import async_playwright, Page
from axe_playwright_python import Axe
import json

async def run_accessibility_audit(url: str, output_file: str = "a11y-report.json"):
    """
    Esegue audit WCAG 2.1 AA con axe-core via Playwright.
    Restituisce violazioni categorizzate per severità.
    """
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        page = await browser.new_page()

        # Simula utente con screen reader (viewport ridotto + riduzione movimento)
        await page.emulate_media(reduced_motion="reduce")
        await page.goto(url, wait_until="networkidle")

        axe = Axe()
        results = await axe.run(
            page,
            options={
                "runOnly": {
                    "type": "tag",
                    "values": ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]
                }
            }
        )

        # Categorizza per severità
        violations_by_impact = {
            "critical": [],
            "serious": [],
            "moderate": [],
            "minor": []
        }

        for violation in results.violations:
            impact = violation.impact or "minor"
            violations_by_impact[impact].append({
                "id": violation.id,
                "description": violation.description,
                "help_url": violation.help_url,
                "nodes": len(violation.nodes),
                "elements": [node.html for node in violation.nodes[:3]]  # Max 3 esempi
            })

        report = {
            "url": url,
            "timestamp": asyncio.get_event_loop().time(),
            "violations_total": len(results.violations),
            "passes": len(results.passes),
            "incomplete": len(results.incomplete),
            "violations_by_impact": violations_by_impact,
            "wcag_coverage": "WCAG 2.1 AA"
        }

        with open(output_file, "w") as f:
            json.dump(report, f, indent=2, ensure_ascii=False)

        # Fallisce la CI se ci sono violazioni critical/serious
        critical_count = len(violations_by_impact["critical"])
        serious_count = len(violations_by_impact["serious"])

        if critical_count + serious_count > 0:
            print(f"FAIL: {critical_count} critical, {serious_count} serious violations")
            return False

        print(f"PASS: 0 critical/serious violations, {len(results.violations)} total")
        await browser.close()
        return True

# Utilizzo in pipeline CI/CD
async def ci_accessibility_check():
    pages_to_check = [
        "https://servizi.comune.esempio.it/",
        "https://servizi.comune.esempio.it/richiesta-certificato",
        "https://servizi.comune.esempio.it/login",
    ]

    all_pass = True
    for url in pages_to_check:
        result = await run_accessibility_audit(url, f"a11y-{url.split('/')[-1] or 'home'}.json")
        all_pass = all_pass and result

    return 0 if all_pass else 1

asyncio.run(ci_accessibility_check())

Ruční testování pomocí čtečky obrazovky

Ruční testování pomocí čtečky obrazovky je nenahraditelné. Hlavní čtečky obrazovky k testování jsou:

  • NVDA (NonVisual Desktop Access) + Chrome/Firefox: nejpoužívanější ve Windows, zdarma, open source. Výchozí kombinace v testech AGID.
  • ČELISTI (Job Access With Speech) + Chrome: nejoblíbenější profesionální čtečka obrazovky, za poplatek. Používá mnoho profesionálních uživatelů.
  • VoiceOver + Safari: na macOS a iOS. Základní test pro zařízení Apple.
  • TalkBack + Chrome: Nativní čtečka obrazovky pro Android. Základ pro mobilní služby.

Kontrolní seznam ručního testování se čtečkou obrazovky

  • Procházejte celou stránku pouze pomocí Tab: každý interaktivní prvek musí být dosažitelný a použitelný
  • Zkontrolujte, zda je název stránky oznámen při přistání (WCAG 2.4.2)
  • Otestujte formuláře: každý štítek musí být přečten před polem, chyby musí být oznámeny
  • Testovací modály: zaměření by se mělo přesunout na modal při otevírání a zpět na spoušť při zavírání
  • Zkontrolujte strukturu nadpisu: musí být logická a nesmí přeskakovat úrovně (h1 > h2 > h3)
  • Testovací tabulky: záhlaví řádků a sloupců musí být spojeno s buňkami (rozsah, id/záhlaví)
  • Zkontrolujte odkazy: „Klikněte sem“ je nepřístupné; text odkazu musí popisovat cíl
  • Otestujte živá oznámení: aria-live="polite" pro aktualizace, které nejsou naléhavé, "assertivní" pro kritické chyby

Prohlášení o přístupnosti AGID

Každá PA musí zveřejnit na svých webových stránkách a Prohlášení o přístupnosti vyhovující ke struktuře definované AGID (evropský model). Prohlášení musí obsahovat: stav shody (vyhovuje / částečně vyhovuje / nevyhovuje), obsah není s uvedením důvodu dostupný, kontakty na zprávy, odkaz na mechanismus hlášení AGID a datum poslední aktualizace.

Cíle přístupnosti musí každoročně zveřejňovat 31. března, jak předpokládá tříletý plán ICT 2024-2026. Selhání zveřejnění nebo se prohlášení neaktualizuje je schvalovatelná ze strany AGID.

Závěry a další kroky

Dostupnost WCAG 2.1 AA není jen regulačním požadavkem: je to ukazatel kvality kodexu a péče o uživatele. Přístupné rozhraní funguje lépe pro každého: navigace klávesnice pomáhá náročným uživatelům, vysoké kontrasty zlepšují čitelnost za jasných podmínek obtížné, alternativní texty zlepšují SEO.

Se vstupem Evropského aktu o přístupnosti v platnost v červnu 2025 se přístupnost stává také povinné pro soukromé společnosti, které nabízejí digitální služby, takže popsané dovednosti v tomto článku ještě cennější na trhu.

Související články v této sérii

  • GovTech #03: Open Data API Design – publikujte a konzumujte veřejná data
  • GovTech #04: GDPR-by-Design - architektonické vzory pro veřejné služby
  • GovTech #06: Integrace vládního API - SPID, CIE a pagoPA