Quelltext des aktuellen Standes
<?php
declare(strict_types=1);
use OpenNIT\Core\AccessibilityScan;
/**
* Hält die Barrierefreiheit der Vorlagen fest.
*
* Drei Regeln stehen auf null und müssen dort bleiben: Jedes Bedienelement
* trägt einen Namen, jedes Bild einen Alternativtext, jeder Tabellenkopf eine
* Richtung. Für die vierte – Eingaben ohne Beschriftung – gilt eine Obergrenze,
* die nur fallen darf: So bleibt der Rest sichtbar, ohne dass eine neue Vorlage
* ihn stillschweigend vergrößert.
*
* Geprüft wird der Quelltext. Tastaturbedienung, Fokusreihenfolge und
* Vorlese-Software lassen sich damit nicht prüfen – das bleibt ein Durchgang
* im Browser.
*/
return static function (): void {
/** Verbleibende Eingaben ohne Beschriftung. Diese Zahl darf nur sinken. */
$obergrenze = 262;
$vorlagen = static function (): array {
$dateien = [];
$lauf = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator(__DIR__ . '/../templates', FilesystemIterator::SKIP_DOTS)
);
foreach ($lauf as $datei) {
if ($datei->isFile() && $datei->getExtension() === 'php') {
$dateien[] = $datei->getPathname();
}
}
sort($dateien);
return $dateien;
};
$funde = [];
foreach ($vorlagen() as $datei) {
foreach (AccessibilityScan::pruefe((string)file_get_contents($datei)) as $fund) {
$funde[$fund['regel']][] = basename($datei) . ':' . $fund['zeile'] . ' ' . $fund['fund'];
}
}
// ----------------------------------------------------------------
// Regeln ohne Ausnahme
// ----------------------------------------------------------------
test('Jedes Bedienelement trägt einen Namen', function () use ($funde) {
$offen = $funde['bedienelement-ohne-namen'] ?? [];
assertSame([], $offen, 'Schaltfläche oder Verweis ohne aria-label/title und ohne Text');
});
test('Jedes Bild trägt einen Alternativtext', function () use ($funde) {
$offen = $funde['bild-ohne-alternativtext'] ?? [];
assertSame([], $offen, 'alt fehlt – bei Zierbildern gehört alt="" hin');
});
test('Jeder Tabellenkopf nennt seine Richtung', function () use ($funde) {
$offen = $funde['tabellenkopf-ohne-richtung'] ?? [];
assertSame([], $offen, 'th ohne scope="col"/"row"');
});
test('Eingaben ohne Beschriftung werden nicht mehr', function () use ($funde, $obergrenze) {
$anzahl = count($funde['eingabe-ohne-beschriftung'] ?? []);
assertTrue(
$anzahl <= $obergrenze,
"Eingaben ohne Beschriftung: {$anzahl}, erlaubt sind höchstens {$obergrenze}"
);
});
// ----------------------------------------------------------------
// Der Rahmen
// ----------------------------------------------------------------
test('Beide Layouts führen zum Inhalt und benennen ihre Bereiche', function () {
foreach (['main', 'admin'] as $layout) {
$quelle = (string)file_get_contents(__DIR__ . '/../templates/layouts/' . $layout . '.php');
assertTrue(str_contains($quelle, 'class="skip-link" href="#main-content"'), $layout . ': Sprungmarke fehlt');
assertTrue(str_contains($quelle, 'id="main-content" tabindex="-1"'), $layout . ': Ziel der Sprungmarke fehlt');
assertTrue(
preg_match('/<nav\b(?![^>]*aria-label)/', $quelle) !== 1,
$layout . ': Ein <nav> ohne aria-label'
);
assertTrue(str_contains($quelle, '/assets/js/dialog.js'), $layout . ': dialog.js nicht eingebunden');
}
});
test('Der Tastaturfokus ist zentral sichtbar gemacht', function () {
$css = (string)file_get_contents(__DIR__ . '/../public/assets/css/app.css');
assertTrue(str_contains($css, ':focus-visible {'), 'Keine Regel für :focus-visible');
assertTrue(str_contains($css, 'var(--focus-ring-color)'), 'Der Ring nutzt kein Token');
assertTrue(str_contains($css, '@media (prefers-reduced-motion: reduce)'), 'Bewegung lässt sich nicht abbestellen');
assertTrue(str_contains($css, '@media (forced-colors: active)'), 'Kein Ring im Kontrastmodus');
assertTrue(str_contains($css, '.visually-hidden'), 'Keine Klasse für Text nur für Vorlese-Software');
$tokens = (string)file_get_contents(__DIR__ . '/../public/assets/css/tokens.css');
foreach (['--focus-ring-color', '--focus-ring-contrast', '--focus-ring-width', '--focus-ring-offset'] as $token) {
assertTrue(str_contains($tokens, $token), 'Token fehlt: ' . $token);
}
});
test('Dialoge sind mit der Tastatur bedienbar', function () {
$js = (string)file_get_contents(__DIR__ . '/../public/assets/js/dialog.js');
// Fokusfalle, Rücksprung, Escape und die Ansage als Dialog.
assertTrue(str_contains($js, "e.key === 'Escape'"), 'Escape schließt nicht');
assertTrue(str_contains($js, "e.key !== 'Tab'"), 'Keine Fokusfalle für Tabulator');
assertTrue(str_contains($js, "setAttribute('aria-modal', 'true')"), 'Der Dialog meldet sich nicht als solcher');
assertTrue(str_contains($js, 'aria-labelledby'), 'Der Dialog bekommt keinen Namen');
assertTrue(str_contains($js, 'eintrag.herkunft'), 'Der Fokus kehrt nicht zurück');
assertTrue(str_contains($js, 'MutationObserver'), 'Die Sichtbarkeit wird nicht beobachtet');
});
// ----------------------------------------------------------------
// Die Prüfung selbst
// ----------------------------------------------------------------
test('Ein Text, den PHP einsetzt, gilt als Beschriftung', function () {
// Sonst gälte jede Schaltfläche mit übersetztem Text als namenlos.
$quelle = '<button class="x"><?= $this->te(\'Speichern\') ?></button>';
assertSame([], AccessibilityScan::pruefe($quelle));
});
test('Ein Kreuz allein ist kein Name', function () {
$funde = AccessibilityScan::pruefe('<button class="modal-close">×</button>');
assertSame('bedienelement-ohne-namen', $funde[0]['regel'] ?? '');
});
test('Eine Seitenzahl ist ein Name', function () {
assertSame([], AccessibilityScan::pruefe('<a href="?page=2">2</a>'));
});
test('Ein umschließendes label beschriftet die Eingabe', function () {
$mitHuelle = '<label>Aktiv <input type="checkbox" name="a"></label>';
assertSame([], AccessibilityScan::pruefe($mitHuelle));
$ohne = '<span>Aktiv</span> <input type="checkbox" name="a">';
assertSame('eingabe-ohne-beschriftung', AccessibilityScan::pruefe($ohne)[0]['regel'] ?? '');
});
test('Ein label mit for bindet über die id', function () {
$gebunden = '<label for="n">Name</label><input id="n" name="n">';
assertSame([], AccessibilityScan::pruefe($gebunden));
$lose = '<label>Name</label><input id="n" name="n">';
assertSame('eingabe-ohne-beschriftung', AccessibilityScan::pruefe($lose)[0]['regel'] ?? '');
});
test('Skripte und Stile werden nicht geprüft', function () {
$imSkript = '<script>var x = \'<th>\' + \'<img src=a>\';</script>';
assertSame([], AccessibilityScan::pruefe($imSkript));
});
test('Versteckte und absendende Felder brauchen keine Beschriftung', function () {
foreach (['hidden', 'submit', 'button', 'reset', 'image'] as $art) {
assertSame([], AccessibilityScan::pruefe('<input type="' . $art . '" name="a">'), $art);
}
});
};