Zum Inhalt springen

Barrierefreiheit

Auftrag

Ein Mitglied, das mit einem Screenreader arbeitet, schreibt an den Verein. Die Zahl der Treffer im Katalog bleibe stumm, alle Knöpfe «Favorit» klängen gleich, und nach dem Zurück-Knopf lande der Fokus im Suchfeld statt beim Ding, das es gerade angeschaut hat.

Als Mitglied, das mit einem Screenreader arbeitet, möchte ich hören, was sich auf der Seite ändert, und dort weitermachen, wo ich war, damit ich die App ohne Hilfe nutzen kann.

Die Akzeptanzkriterien lauten:

  1. Der Screenreader nennt die Zahl der Treffer, sobald sie sich ändert.
  2. Jeder Knopf und jeder Link nennt, wozu er dient, auch ohne seine Umgebung.
  3. Nach dem Zurück-Knopf steht der Fokus beim Ding, von dem das Mitglied kam.

Lernziele

Nach diesem Kapitel kannst du

  • erklären, welche Regeln für die Barrierefreiheit gelten,
  • mit semantischem HTML, Beschriftungen und Namen arbeiten,
  • den Fokus nach einer Navigation und nach einer Änderung bewusst setzen,
  • Statusmeldungen mit Live-Regionen ausgeben,
  • Kontrast und Tastatur prüfen und eine Seite mit den Werkzeugen des Browsers untersuchen.

Die Web Content Accessibility Guidelines 2.2, kurz WCAG, sind seit Oktober 2023 eine Empfehlung des W3C, zuletzt aktualisiert im Dezember 2024. Ihre Erfolgskriterien haben die Stufen A, AA und AAA, und Vorgaben verlangen meist die Stufe AA. In der Schweiz verlangt der Standard eCH-0059 in der Version 3.0 von 2020 für Websites die WCAG 2.1 auf Stufe AA, als Empfehlung. Das Behindertengleichstellungsgesetz verlangt in Artikel 14, dass Dienstleistungen des Bundes im Internet für Menschen mit einer Sehbehinderung ohne erschwerende Bedingungen zugänglich sind. Eine SPA braucht dabei besondere Sorgfalt, denn ohne neue Seite merkt ein Screenreader nichts von einem Wechsel, wie Kapitel 2.1 gezeigt hat.

Ein button lässt sich mit der Tastatur erreichen und auslösen, ein Screenreader nennt ihn Schalter. Ein div mit @click kann nichts davon. Die Vue-Dokumentation rät darum zu den passenden Elementen, zu Überschriften in ihrer Rangfolge, zu Bereichen wie header, nav, main und footer und zu einem Link zum Überspringen der Navigation als erstes Element der Seite. Die Fallbeispiel-App setzt diesen Link vor den Kopf.

PageLayout.vue
<script setup>
// The skip link moves the focus to the content without changing the address. A link to #main would
// become a navigation of the router in the hash mode of the demo.
function skipToContent() {
document.getElementById('main').focus()
}
</script>
<template>
<div class="layout">
<a class="skip-link" href="#main" @click.prevent="skipToContent">Zum Inhalt springen</a>
<header class="site">
<slot name="header">Lorraine Commons</slot>
</header>
<main id="main" tabindex="-1">
<slot />
</main>

Der Link verschiebt nur den Fokus. Ein gewöhnlicher Link auf #main würde im Hash-Modus der Demo zu einer Navigation des Routers.

Jedes Bedienelement braucht nach WCAG 4.1.2 einen Namen, den Hilfsmittel auslesen können. Der Name kommt aus dem Text des Elements, aus aria-label oder aus Text, der nur für Screenreader da ist. Ein Knopf, der nur ein Kreuz zeigt, hat ohne eines davon keinen brauchbaren Namen.

App.vue
<script setup>
import { ref } from 'vue'
const removed = ref('')
</script>
<template>
<p>Drei Knöpfe mit demselben Zeichen. Nur zwei haben einen Namen für den Screenreader.</p>
<button type="button" @click="removed = '1'">✕</button>
<button type="button" aria-label="Bohrmaschine entfernen" @click="removed = '2'">✕</button>
<button type="button" @click="removed = '3'">
<span aria-hidden="true">✕</span>
<span class="visually-hidden">Beamer entfernen</span>
</button>
<p role="status">{{ removed && `Knopf ${removed} gedrückt` }}</p>
</template>
<style>
/* Hidden on the screen, read by a screen reader */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
</style>

In der Fallbeispiel-App nennen die Knöpfe und Links auf den Karten jetzt das Ding, zu dem sie gehören, mit Text, der nur für Screenreader da ist. Der sichtbare Text steht dabei am Anfang des Namens, damit auch eine Sprachsteuerung den Knopf unter dem Wort findet, das auf dem Bildschirm steht.

Alles muss sich nach WCAG 2.1.1 mit der Tastatur bedienen lassen, in einer sinnvollen Reihenfolge und mit einem sichtbaren Fokus. WCAG 2.2 verlangt zusätzlich, dass eigene Inhalte wie ein festes Banner den Fokus nicht ganz verdecken und dass Ziele für Zeiger mindestens 24 auf 24 Pixel gross sind oder genug Abstand haben. Prüfen lässt sich das ohne Werkzeug. Leg die Maus weg und geh die Seite mit Tab, Shift und Tab, Enter, Leertaste und Escape durch.

Nach einer Navigation setzt die Fallbeispiel-App seit Kapitel 6.5 den Fokus auf die Überschrift der neuen Seite. Kommt jemand mit dem Zurück-Knopf vom Ding auf den Katalog, stellt der Router die alte Position wieder her, und der Fokus landete bisher im Suchfeld weit oben. Jetzt setzt der Katalog den Fokus auf die Karte des Dings, ohne dass die Seite springt.

CatalogView.vue
// Back from the page of a thing, the focus returns to the card of that thing, at the position the
// router restores, and the page does not jump. A new visit starts in the search field.
onMounted(() => {
const cameFrom = window.history.state?.forward
const id = typeof cameFrom === 'string' ? cameFrom.match(/\/items\/(\d+)$/)?.[1] : undefined
const link = id && document.querySelector(`main .card a[href$="/items/${id}"]`)
if (link) link.focus({ preventScroll: true })
else searchBar.value.focus()
})

Vue Router hält in history.state fest, von welcher Adresse jemand zurückkommt, und preventScroll lässt die wiederhergestellte Position stehen. Auch wenn ein Element mit dem Fokus verschwindet, etwa nach dem Entfernen eines Eintrags, muss die App den Fokus selbst weitergeben. Sonst beginnt die Tastatur wieder oben auf der Seite.

App.vue

App.vue
<script setup>
import { nextTick, ref, useTemplateRef } from 'vue'
import { things } from './things.js'
const favorites = ref(things.slice(0, 3))
const heading = useTemplateRef('heading')
const list = useTemplateRef('list')
// The removed button takes its focus along. The focus moves to the entry that takes its place,
// at the end to the one before, and to the heading once the list is empty.
async function remove(index) {
favorites.value = favorites.value.filter((_, position) => position !== index)
await nextTick()
const buttons = list.value?.querySelectorAll('button') ?? []
if (buttons.length > 0) buttons[Math.min(index, buttons.length - 1)].focus()
else heading.value.focus()
}
</script>
<template>
<h2 ref="heading" tabindex="-1">Favoriten</h2>
<ul v-if="favorites.length > 0" ref="list">
<li v-for="(thing, index) in favorites" :key="thing.id">
{{ thing.name }}
<button type="button" @click="remove(index)">Entfernen</button>
</li>
</ul>
<p v-else>Noch keine Favoriten.</p>
</template>

things.js

things.js
// A few things of the catalog
export const things = [
{ id: 1, name: 'Bohrmaschine', category: 'Werkzeug', deposit: 20, availableFrom: null },
{ id: 2, name: 'Zelt für vier Personen', category: 'Camping', deposit: 50, availableFrom: '2026-11-12' },
{ id: 5, name: 'Stichsäge', category: 'Werkzeug', deposit: 20, availableFrom: null },
{ id: 6, name: 'Beamer', category: 'Feste', deposit: 80, availableFrom: null },
{ id: 15, name: 'Kreissäge', category: 'Werkzeug', deposit: 40, availableFrom: '2026-11-05' },
]

Nach WCAG 4.1.3 muss ein Screenreader Statusmeldungen vorlesen können, ohne dass sie den Fokus bekommen. Dafür gibt es Live-Regionen. Laut MDN liest ein Screenreader eine Region mit role="status" vor, sobald er gerade nichts anderes tut, und eine mit role="alert" sofort. Die Region muss schon auf der Seite stehen, bevor sich ihr Inhalt ändert.

App.vue

App.vue
<script setup>
import { computed, ref } from 'vue'
import { things } from './things.js'
const query = ref('')
const hits = computed(() => things.filter((thing) => thing.name.toLowerCase().includes(query.value.trim().toLowerCase())))
</script>
<template>
<label for="search">Suchen</label>
<input id="search" v-model="query" type="search">
<!-- The live region is there from the start, a screen reader announces each new text -->
<p role="status">{{ hits.length }} Dinge gefunden</p>
<ul>
<li v-for="thing in hits" :key="thing.id">{{ thing.name }}</li>
</ul>
</template>

things.js

things.js
// A few things of the catalog
export const things = [
{ id: 1, name: 'Bohrmaschine', category: 'Werkzeug', deposit: 20, availableFrom: null },
{ id: 2, name: 'Zelt für vier Personen', category: 'Camping', deposit: 50, availableFrom: '2026-11-12' },
{ id: 5, name: 'Stichsäge', category: 'Werkzeug', deposit: 20, availableFrom: null },
{ id: 6, name: 'Beamer', category: 'Feste', deposit: 80, availableFrom: null },
{ id: 15, name: 'Kreissäge', category: 'Werkzeug', deposit: 40, availableFrom: '2026-11-05' },
]

Die Fallbeispiel-App meldet so die Zahl der Treffer im Katalog, gesendete Anfragen und erfasste Rückgaben. Fehler beim Laden kommen als role="alert".

Jedes Feld braucht ein label, das mit for auf seine id zeigt. Ein Platzhalter ersetzt laut Vue-Dokumentation keine Beschriftung. Meldet ein Feld einen Fehler, markiert aria-invalid das Feld, und aria-describedby verbindet es mit der Meldung, die ein Screenreader dann zusammen mit der Beschriftung vorliest.

ReservationForm.vue
<form class="fields" novalidate @submit.prevent="submit">
<label for="from">Erster Tag</label>
<input
id="from"
v-model="request.from"
type="date"
:aria-invalid="!!shownErrors.from"
:aria-describedby="shownErrors.from ? 'from-error' : undefined"
@blur="touched.from = true"
>
<p v-if="shownErrors.from" id="from-error" class="error">{{ shownErrors.from }}</p>

Text braucht nach WCAG 1.4.3 einen Kontrast von mindestens 4,5:1 zum Hintergrund, grosser Text 3:1. Für Bedienelemente und wichtige Grafiken verlangt WCAG 1.4.11 mindestens 3:1. Die Fallbeispiel-App prüft ihre Farben für Text in einem Test nach der Formel der WCAG.

accessibility.test.js
// The contrast ratio of WCAG 2.2 between two colors such as #1f6b4f and #ffffff
function contrast(first, second) {
const luminance = (hex) => {
const [r, g, b] = [1, 3, 5].map((start) => parseInt(hex.slice(start, start + 2), 16) / 255)
const linear = (channel) => (channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4)
return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b)
}
const [light, dark] = [luminance(first), luminance(second)].sort((a, b) => b - a)
return (light + 0.05) / (dark + 0.05)
}

Die Entwicklertools von Chrome zeigen im Bereich «Accessibility» den Baum, den Hilfsmittel lesen, mit Rolle und Namen jedes Elements. Lighthouse bewertet die Barrierefreiheit einer Seite automatisch und listet weitere Punkte, die jemand von Hand prüfen muss. Der Accessibility Inspector von Firefox sucht mit «Check for issues» nach Problemen bei Kontrast, Tastatur und Beschriftungen. Ein Screenreader zeigt am deutlichsten, wie eine Seite klingt. Windows bringt die Sprachausgabe mit, macOS VoiceOver, und NVDA gibt es frei für Windows.

Der Mindestumfang verlangt die Grundregeln der Barrierefreiheit. Prüf vor der Abgabe, dass jedes Feld beschriftet ist, dass Knöpfe handeln und Links navigieren, dass jedes Bedienelement einen Namen hat, dass der Fokus nach einer Navigation sinnvoll steht, dass Meldungen in Live-Regionen erscheinen und dass Text den Kontrast erreicht. Bedien die App einmal nur mit der Tastatur und lass Lighthouse laufen.

Arbeite mit den Beispielen dieses Kapitels und mit der Fallbeispiel-App. Schreib auf, was du erwartest, bevor du etwas ausprobierst.

  1. Öffne im Beispiel mit den drei Knöpfen die Entwicklertools und den Bereich «Accessibility». Welchen Namen hat jeder Knopf?
  2. Nimm im Beispiel mit der Live-Region role="status" weg. Was ändert sich auf dem Bildschirm, und was für einen Screenreader?
  3. Nimm im Beispiel zum Entfernen die Zeilen weg, die den Fokus setzen, und entferne einen Eintrag mit der Tastatur. Wohin führt der nächste Tab?
  4. Lade in der Fallbeispiel-App den Katalog und drück Tab. Welches Element bekommt den Fokus zuerst, und was passiert mit Enter?

Die Aufgaben machen eine Seite mit Suche, Favoriten und einem Formular barrierefrei. Du findest das Projekt im Kursrepo im Ordner exercises/08-architecture/accessibility/de. Kopiere den Ordner, installiere die Pakete und starte die Tests.

Terminal-Fenster
npm install
npm test

Aufgabe Einstieg

Gib dem Suchfeld und der Auswahl der Kategorie je ein label. Mach aus dem Stern für Favoriten einen button mit aria-pressed, dessen Name das Ding nennt, und gib den Knöpfen zum Entfernen einen Namen mit dem Ding.

Lösung
SearchPanel.vue
<template>
<section>
<h2>Suche</h2>
<label for="search">Suchen</label>
<input id="search" v-model="query" type="search">
<label for="category">Kategorie</label>
<select id="category" v-model="category">
<option value="">Alle Kategorien</option>
<option v-for="name in categories" :key="name">{{ name }}</option>
</select>
<!-- A live region exists from the start, a screen reader announces every new number -->
<p class="hits" role="status">{{ visible.length }} Dinge gefunden</p>
<ul class="catalog">
<li v-for="item in visible" :key="item.id">
{{ item.name }}
<!-- A real button with its state, the star is decoration and the hidden text its name -->
<button type="button" class="favorite" :aria-pressed="favoriteIds.includes(item.id)" @click="toggleFavorite(item.id)">
<span aria-hidden="true">{{ favoriteIds.includes(item.id) ? '★' : '☆' }}</span>
<span class="visually-hidden">Favorit: {{ item.name }}</span>
</button>
</li>
</ul>
</section>
</template>

Der Stern bleibt sichtbar, aria-hidden verbirgt ihn vor dem Screenreader, und der versteckte Text liefert den Namen.

Aufgabe Vertiefung

Mach die Zahl der Treffer zu einer Live-Region, die von Anfang an auf der Seite steht. Markiere im Formular ein Feld mit Fehler mit aria-invalid und verbinde es mit aria-describedby mit seiner Meldung. Ist das Feld wieder richtig, verschwindet die Verbindung.

Lösung
RequestForm.vue
<form class="fields" novalidate @submit.prevent="submit">
<label for="from">Erster Tag</label>
<!-- aria-invalid marks the field, aria-describedby reads the message together with its label -->
<input
id="from"
v-model="request.from"
type="date"
:aria-invalid="!!shown.from"
:aria-describedby="shown.from ? 'from-error' : undefined"
>
<p v-if="shown.from" id="from-error" class="error">{{ shown.from }}</p>
<label for="to">Letzter Tag</label>
<input
id="to"
v-model="request.to"
type="date"
:aria-invalid="!!shown.to"
:aria-describedby="shown.to ? 'to-error' : undefined"
>
<p v-if="shown.to" id="to-error" class="error">{{ shown.to }}</p>

Aufgabe Knobelei

Wer einen Favoriten entfernt, verliert mit dem Knopf den Fokus. Setz ihn auf den Eintrag, der nachrückt, am Ende der Liste auf den davor und auf die Überschrift, wenn die Liste leer ist.

Lösung
FavoritesList.vue
<script setup>
import { nextTick, useTemplateRef } from 'vue'
defineProps({ items: { type: Array, required: true } })
const emit = defineEmits(['remove'])
const heading = useTemplateRef('heading')
const list = useTemplateRef('list')
// The button disappears together with its focus. The focus moves to the entry that takes its place,
// at the end of the list to the one before, and to the heading once the list is empty.
async function remove(id, index) {
emit('remove', id)
await nextTick()
const buttons = list.value?.querySelectorAll('button.remove') ?? []
if (buttons.length > 0) buttons[Math.min(index, buttons.length - 1)].focus()
else heading.value.focus()
}
</script>
<template>
<section>
<h2 ref="heading" tabindex="-1">Favoriten</h2>
<ul v-if="items.length > 0" ref="list" class="favorites">
<li v-for="(item, index) in items" :key="item.id">
{{ item.name }}
<button type="button" class="remove" @click="remove(item.id, index)">
<span aria-hidden="true">✕</span>
<span class="visually-hidden">Entfernen: {{ item.name }}</span>
</button>
</li>
</ul>
<p v-else>Noch keine Favoriten.</p>
</section>
</template>

nextTick wartet, bis Vue die Liste neu gezeichnet hat. Die Überschrift bekommt tabindex="-1", damit sie den Fokus nehmen kann, ohne in die Reihenfolge der Tabulatortaste zu kommen.

FehlerWas hilft
Ein div mit @click dient als Knopf.Ein button bringt Rolle, Fokus und Tastatur mit.
Ein Feld hat nur einen Platzhalter.Ein label mit for verwenden.
Eine Live-Region erscheint erst mit ihrer Meldung und bleibt stumm.Die Region von Anfang an zeigen und nur ihren Inhalt ändern.
Nach dem Entfernen eines Eintrags beginnt die Tastatur wieder oben.Den Fokus auf den nächsten Eintrag oder eine Überschrift setzen.
outline: none nimmt der Tastatur die Orientierung.Den sichtbaren Fokus behalten oder einen eigenen gestalten, wie WCAG 2.4.7 verlangt.
Ein KI-Assistent setzt aria-label auf Elemente mit sichtbarem Text und überschreibt damit deren Namen.Sichtbarer Text ist meist der beste Name. ARIA nur dort einsetzen, wo HTML nicht reicht.

Selbstcheck

  1. Welche Stufe der WCAG verlangen die meisten Vorgaben, und welche Version ist die aktuelle?
  2. Woher bekommt ein Knopf seinen Namen?
  3. Warum muss eine SPA nach einer Navigation über den Fokus entscheiden?
  4. Wie meldet eine Seite eine Änderung, ohne den Fokus zu verschieben?
Antworten
  1. Die Stufe AA, aktuell ist die WCAG 2.2.
  2. Aus seinem Text, aus aria-label oder aus Text, der nur für Screenreader da ist.
  3. Ohne neue Seite bleibt der Fokus, wo er war, und ein Screenreader merkt den Wechsel nicht.
  4. Mit einer Live-Region, etwa einem Element mit role="status", das schon auf der Seite steht.

Auftrag

Das Mitglied schreibt zurück, dass es den Katalog jetzt ohne Hilfe bedienen kann. Martina Hostettler will die App nun an der Mitgliederversammlung zeigen, öffentlich erreichbar und ohne Server des Vereins. Das nächste Kapitel baut und veröffentlicht sie.

Texte und Abbildungen stehen unter CC BY-SA 4.0, der Code unter der MIT-Lizenz und die Daten des Kurses unter CC0 1.0. Die Schrift der Abbildungen ist Kalam unter der SIL Open Font License 1.1. Die Quellen liegen auf GitHub.