Multi-Language MVP: 4 Locales (DE/EN/UK/RU) mit SSR + UI + Tests #79
@@ -32,15 +32,25 @@ class HomeController extends Controller
|
||||
};
|
||||
|
||||
// ── Pull flashed state ────────────────────────────────────────
|
||||
$formSuccess = !empty($_SESSION['form_success']);
|
||||
$formErrors = $_SESSION['form_errors'] ?? [];
|
||||
$formData = $_SESSION['form_data'] ?? null;
|
||||
unset($_SESSION['form_success'], $_SESSION['form_errors'], $_SESSION['form_data']);
|
||||
$formSuccess = !empty($_SESSION['form_success']);
|
||||
$formErrors = $_SESSION['form_errors'] ?? [];
|
||||
$formFieldErrors = $_SESSION['form_field_errors'] ?? [];
|
||||
$formData = $_SESSION['form_data'] ?? null;
|
||||
unset(
|
||||
$_SESSION['form_success'],
|
||||
$_SESSION['form_errors'],
|
||||
$_SESSION['form_field_errors'],
|
||||
$_SESSION['form_data'],
|
||||
);
|
||||
|
||||
if ($formSuccess) {
|
||||
$formData = ['fname' => '', 'lname' => '', 'email' => '', 'phone' => '', 'interest' => 'visit', 'message' => ''];
|
||||
$formData = self::emptyFormData();
|
||||
$formFieldErrors = [];
|
||||
} elseif (!is_array($formData)) {
|
||||
$formData = ['fname' => '', 'lname' => '', 'email' => '', 'phone' => '', 'interest' => 'visit', 'message' => ''];
|
||||
$formData = self::emptyFormData();
|
||||
$formFieldErrors = is_array($formFieldErrors) ? $formFieldErrors : [];
|
||||
} else {
|
||||
$formFieldErrors = is_array($formFieldErrors) ? $formFieldErrors : [];
|
||||
}
|
||||
|
||||
// ── CSRF token ────────────────────────────────────────────────
|
||||
@@ -72,17 +82,19 @@ class HomeController extends Controller
|
||||
exit;
|
||||
}
|
||||
|
||||
// Per-field errors enable aria-invalid + aria-describedby.
|
||||
$formFieldErrors = [];
|
||||
if ($formData['fname'] === '') {
|
||||
$formErrors[] = 'form.error.fname_required';
|
||||
$formFieldErrors['fname'][] = 'form.error.fname_required';
|
||||
}
|
||||
if ($formData['lname'] === '') {
|
||||
$formErrors[] = 'form.error.lname_required';
|
||||
$formFieldErrors['lname'][] = 'form.error.lname_required';
|
||||
}
|
||||
if ($formData['email'] === '' || !filter_var($formData['email'], FILTER_VALIDATE_EMAIL)) {
|
||||
$formErrors[] = 'form.error.email_invalid';
|
||||
$formFieldErrors['email'][] = 'form.error.email_invalid';
|
||||
}
|
||||
if ($formData['message'] === '') {
|
||||
$formErrors[] = 'form.error.message_required';
|
||||
$formFieldErrors['message'][] = 'form.error.message_required';
|
||||
}
|
||||
if ($containsHeaderInjection($formData['email']) || $containsHeaderInjection($formData['fname'] . ' ' . $formData['lname'])) {
|
||||
$formErrors[] = 'form.error.header_injection';
|
||||
@@ -98,7 +110,7 @@ class HomeController extends Controller
|
||||
$formErrors[] = 'form.error.rate_limit';
|
||||
}
|
||||
|
||||
if (empty($formErrors)) {
|
||||
if (empty($formErrors) && empty($formFieldErrors)) {
|
||||
$interestKey = self::INTEREST_KEYS[$formData['interest']] ?? 'form.interest.visit';
|
||||
$interestLabel = I18n::t($interestKey, [], $locale);
|
||||
|
||||
@@ -129,8 +141,9 @@ class HomeController extends Controller
|
||||
$formErrors[] = 'form.error.send_failed';
|
||||
}
|
||||
|
||||
$_SESSION['form_errors'] = $formErrors;
|
||||
$_SESSION['form_data'] = $formData;
|
||||
$_SESSION['form_errors'] = $formErrors;
|
||||
$_SESSION['form_field_errors'] = $formFieldErrors;
|
||||
$_SESSION['form_data'] = $formData;
|
||||
header('Location: /#kontakt');
|
||||
exit;
|
||||
}
|
||||
@@ -177,10 +190,26 @@ class HomeController extends Controller
|
||||
$this->render('home/index', [
|
||||
'formSuccess' => $formSuccess,
|
||||
'formErrors' => $formErrors,
|
||||
'formFieldErrors' => $formFieldErrors,
|
||||
'formData' => $formData,
|
||||
'interestKeys' => self::INTEREST_KEYS,
|
||||
'escapeContactValue' => $escapeContactValue,
|
||||
'structuredData' => $structuredData,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{fname: string, lname: string, email: string, phone: string, interest: string, message: string}
|
||||
*/
|
||||
private static function emptyFormData(): array
|
||||
{
|
||||
return [
|
||||
'fname' => '',
|
||||
'lname' => '',
|
||||
'email' => '',
|
||||
'phone' => '',
|
||||
'interest' => 'visit',
|
||||
'message' => '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +178,8 @@ return [
|
||||
'footer.address' => 'Bahnhofstraße 10 · Schleusingen',
|
||||
'footer.imprint' => 'Impressum',
|
||||
'footer.privacy' => 'Datenschutz',
|
||||
'footer.aria' => 'Fußbereich',
|
||||
'a11y.main' => 'Hauptinhalt',
|
||||
|
||||
// ─── Lightbox ────────────────────────────────────────────────────────
|
||||
'lightbox.aria' => 'Bildansicht',
|
||||
|
||||
@@ -163,6 +163,8 @@ return [
|
||||
'footer.address' => 'Bahnhofstraße 10 · Schleusingen',
|
||||
'footer.imprint' => 'Imprint',
|
||||
'footer.privacy' => 'Privacy policy',
|
||||
'footer.aria' => 'Footer',
|
||||
'a11y.main' => 'Main content',
|
||||
|
||||
'lightbox.aria' => 'Image view',
|
||||
'lightbox.close' => 'Close image view',
|
||||
|
||||
@@ -163,6 +163,8 @@ return [
|
||||
'footer.address' => 'Bahnhofstraße 10 · Шлайзинген',
|
||||
'footer.imprint' => 'Импрессум',
|
||||
'footer.privacy' => 'Политика конфиденциальности',
|
||||
'footer.aria' => 'Подвал сайта',
|
||||
'a11y.main' => 'Основное содержимое',
|
||||
|
||||
'lightbox.aria' => 'Просмотр изображения',
|
||||
'lightbox.close' => 'Закрыть просмотр изображения',
|
||||
|
||||
@@ -163,6 +163,8 @@ return [
|
||||
'footer.address' => 'Bahnhofstraße 10 · Шлайзінген',
|
||||
'footer.imprint' => 'Імпресум',
|
||||
'footer.privacy' => 'Політика конфіденційності',
|
||||
'footer.aria' => 'Нижній колонтитул',
|
||||
'a11y.main' => 'Головний вміст',
|
||||
|
||||
'lightbox.aria' => 'Перегляд зображення',
|
||||
'lightbox.close' => 'Закрити перегляд зображення',
|
||||
|
||||
@@ -275,29 +275,41 @@ $gridItems = [
|
||||
<div class="form-field">
|
||||
<label for="fname"><?= htmlspecialchars($t('contact.fname'), ENT_QUOTES) ?></label>
|
||||
<input type="text" id="fname" name="fname" required maxlength="80" autocomplete="given-name"
|
||||
value="<?= $escapeContactValue($formData['fname'] ?? '') ?>">
|
||||
value="<?= $escapeContactValue($formData['fname'] ?? '') ?>"
|
||||
<?= !empty($formFieldErrors['fname']) ? 'aria-invalid="true" aria-describedby="err-fname"' : '' ?>>
|
||||
<?php if (!empty($formFieldErrors['fname'])): ?>
|
||||
<p id="err-fname" class="form-field-error"><?= htmlspecialchars($t($formFieldErrors['fname'][0]), ENT_QUOTES) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="lname"><?= htmlspecialchars($t('contact.lname'), ENT_QUOTES) ?></label>
|
||||
<input type="text" id="lname" name="lname" required maxlength="80" autocomplete="family-name"
|
||||
value="<?= $escapeContactValue($formData['lname'] ?? '') ?>">
|
||||
value="<?= $escapeContactValue($formData['lname'] ?? '') ?>"
|
||||
<?= !empty($formFieldErrors['lname']) ? 'aria-invalid="true" aria-describedby="err-lname"' : '' ?>>
|
||||
<?php if (!empty($formFieldErrors['lname'])): ?>
|
||||
<p id="err-lname" class="form-field-error"><?= htmlspecialchars($t($formFieldErrors['lname'][0]), ENT_QUOTES) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-row">
|
||||
<div class="form-field">
|
||||
<label for="email"><?= htmlspecialchars($t('contact.email'), ENT_QUOTES) ?></label>
|
||||
<input type="email" id="email" name="email" required maxlength="120" autocomplete="email"
|
||||
value="<?= $escapeContactValue($formData['email'] ?? '') ?>">
|
||||
value="<?= $escapeContactValue($formData['email'] ?? '') ?>"
|
||||
<?= !empty($formFieldErrors['email']) ? 'aria-invalid="true" aria-describedby="err-email"' : '' ?>>
|
||||
<?php if (!empty($formFieldErrors['email'])): ?>
|
||||
<p id="err-email" class="form-field-error"><?= htmlspecialchars($t($formFieldErrors['email'][0]), ENT_QUOTES) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="phone"><?= htmlspecialchars($t('contact.phone'), ENT_QUOTES) ?></label>
|
||||
<input type="tel" id="phone" name="phone" maxlength="40" autocomplete="tel"
|
||||
value="<?= $escapeContactValue($formData['phone'] ?? '') ?>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<div class="form-field">
|
||||
<label for="interest"><?= htmlspecialchars($t('contact.interest'), ENT_QUOTES) ?></label>
|
||||
<select id="interest" name="interest" required>
|
||||
<?php
|
||||
@@ -314,13 +326,18 @@ $gridItems = [
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<div class="form-field">
|
||||
<label for="message"><?= htmlspecialchars($t('contact.message'), ENT_QUOTES) ?></label>
|
||||
<textarea id="message" name="message" required rows="6" maxlength="2000"
|
||||
placeholder="<?= htmlspecialchars($t('contact.message'), ENT_QUOTES) ?>"><?= $escapeContactValue($formData['message'] ?? '') ?></textarea>
|
||||
</div>
|
||||
placeholder="<?= htmlspecialchars($t('contact.message'), ENT_QUOTES) ?>"
|
||||
<?= !empty($formFieldErrors['message']) ? 'aria-invalid="true" aria-describedby="err-message"' : ''
|
||||
?>><?= $escapeContactValue($formData['message'] ?? '') ?></textarea>
|
||||
<?php if (!empty($formFieldErrors['message'])): ?>
|
||||
<p id="err-message" class="form-field-error"><?= htmlspecialchars($t($formFieldErrors['message'][0]), ENT_QUOTES) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="form-submit"><?= htmlspecialchars($t('contact.submit'), ENT_QUOTES) ?></button>
|
||||
|
||||
|
||||
@@ -114,11 +114,11 @@ $navItems = [
|
||||
<div id="navMobile" class="nav-mobile-overlay" hidden></div>
|
||||
</nav>
|
||||
|
||||
<main id="main" tabindex="-1">
|
||||
<main id="main" tabindex="-1" aria-label="<?= htmlspecialchars($t('a11y.main'), ENT_QUOTES) ?>">
|
||||
<?= $content ?>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<footer aria-label="<?= htmlspecialchars($t('footer.aria'), ENT_QUOTES) ?>">
|
||||
<div class="footer-logo">
|
||||
<span class="logo-icon" aria-hidden="true">🏠</span>
|
||||
<span><?= htmlspecialchars($t('footer.address'), ENT_QUOTES) ?></span>
|
||||
|
||||
@@ -1435,3 +1435,23 @@ nav.scrolled .locale-switcher__option.is-current {
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* FORM FIELD ERRORS (sub-Issue E) */
|
||||
.form-field-error {
|
||||
margin: 0.375rem 0 0;
|
||||
font-size: 0.875rem;
|
||||
color: #b91c1c;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.form-field input[aria-invalid="true"],
|
||||
.form-field textarea[aria-invalid="true"] {
|
||||
border-color: #b91c1c;
|
||||
outline-color: #b91c1c;
|
||||
}
|
||||
|
||||
.form-field input[aria-invalid="true"]:focus-visible,
|
||||
.form-field textarea[aria-invalid="true"]:focus-visible {
|
||||
outline: 2px solid #b91c1c;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
202
tests/Views/HomeViewA11yTest.php
Normal file
202
tests/Views/HomeViewA11yTest.php
Normal file
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Views;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Integration test: render the full home view with various
|
||||
* form-error scenarios and verify the emitted HTML exposes
|
||||
* accessibility hooks (aria-invalid, aria-describedby,
|
||||
* role/aria-live, etc.).
|
||||
*
|
||||
* Strategy: include the view file directly after populating the
|
||||
* locals it expects. We bypass the controller and session start
|
||||
* to keep the test self-contained.
|
||||
*/
|
||||
final class HomeViewA11yTest extends TestCase
|
||||
{
|
||||
/** @var array<string, string> */
|
||||
private array $translations;
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
private array $session = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
// Use the real de.php translations so we never drift from
|
||||
// production copy. Anything missing returns the key in
|
||||
// brackets (matches I18n::t()'s dev fallback).
|
||||
$de = require dirname(__DIR__, 2) . '/app/Locales/de.php';
|
||||
$this->translations = $this->flatten($de);
|
||||
|
||||
$this->session = [
|
||||
'csrf_token' => str_repeat('a', 64),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a nested translation array to dot.notation keys.
|
||||
*
|
||||
* @param array<string,mixed> $arr
|
||||
* @return array<string,string>
|
||||
*/
|
||||
private function flatten(array $arr, string $prefix = ''): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($arr as $key => $val) {
|
||||
$full = $prefix === '' ? (string) $key : $prefix . '.' . $key;
|
||||
if (is_array($val)) {
|
||||
$out += $this->flatten($val, $full);
|
||||
} else {
|
||||
$out[$full] = (string) $val;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function formResultHasAriaLivePolite(): void
|
||||
{
|
||||
$html = $this->renderHomeView(formErrors: [], formFieldErrors: [], formSuccess: false);
|
||||
|
||||
self::assertStringContainsString('id="form-result"', $html);
|
||||
self::assertStringContainsString('aria-live="polite"', $html);
|
||||
self::assertStringContainsString('role="status"', $html);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function errorListHasRoleAlert(): void
|
||||
{
|
||||
$html = $this->renderHomeView(
|
||||
formErrors: ['form.error.csrf'],
|
||||
formFieldErrors: [],
|
||||
formSuccess: false,
|
||||
);
|
||||
|
||||
self::assertStringContainsString('class="form-errors"', $html);
|
||||
self::assertStringContainsString('role="alert"', $html);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fieldErrorRendersAriaInvalidAndDescribedBy(): void
|
||||
{
|
||||
$html = $this->renderHomeView(
|
||||
formErrors: [],
|
||||
formFieldErrors: [
|
||||
'fname' => ['form.error.fname_required'],
|
||||
'email' => ['form.error.email_invalid'],
|
||||
'message' => ['form.error.message_required'],
|
||||
],
|
||||
formSuccess: false,
|
||||
);
|
||||
|
||||
self::assertStringContainsString('aria-invalid="true"', $html);
|
||||
self::assertStringContainsString('aria-describedby="err-fname"', $html);
|
||||
self::assertStringContainsString('aria-describedby="err-email"', $html);
|
||||
self::assertStringContainsString('aria-describedby="err-message"', $html);
|
||||
|
||||
self::assertStringContainsString('id="err-fname" class="form-field-error"', $html);
|
||||
self::assertStringContainsString('Bitte geben Sie Ihren Vornamen an.', $html);
|
||||
self::assertStringContainsString('id="err-email" class="form-field-error"', $html);
|
||||
self::assertStringContainsString('Bitte geben Sie eine gültige E-Mail-Adresse an.', $html);
|
||||
self::assertStringContainsString('id="err-message" class="form-field-error"', $html);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fieldsWithoutErrorsDoNotHaveAriaInvalid(): void
|
||||
{
|
||||
$html = $this->renderHomeView(
|
||||
formErrors: [],
|
||||
formFieldErrors: ['fname' => ['form.error.fname_required']],
|
||||
formSuccess: false,
|
||||
);
|
||||
|
||||
$count = substr_count($html, 'aria-invalid="true"');
|
||||
self::assertSame(1, $count, 'Only fields with errors should carry aria-invalid');
|
||||
|
||||
self::assertStringNotContainsString('aria-describedby="err-lname"', $html);
|
||||
self::assertStringNotContainsString('aria-describedby="err-email"', $html);
|
||||
self::assertStringNotContainsString('aria-describedby="err-message"', $html);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function successMessageRendersInsideFormResult(): void
|
||||
{
|
||||
$html = $this->renderHomeView(formErrors: [], formFieldErrors: [], formSuccess: true);
|
||||
|
||||
self::assertStringContainsString('class="form-success"', $html);
|
||||
self::assertStringContainsString('Vielen Dank für Ihre Anfrage!', $html);
|
||||
self::assertStringContainsString('Wir haben Ihre Nachricht erhalten', $html);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function honeypotFieldIsVisuallyHidden(): void
|
||||
{
|
||||
$html = $this->renderHomeView(formErrors: [], formFieldErrors: [], formSuccess: false);
|
||||
|
||||
self::assertStringContainsString('class="form-hp"', $html);
|
||||
self::assertStringContainsString('aria-hidden="true"', $html);
|
||||
self::assertStringContainsString('tabindex="-1"', $html);
|
||||
self::assertStringContainsString('autocomplete="off"', $html);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function csrfTokenIsEmbeddedAsHiddenField(): void
|
||||
{
|
||||
$html = $this->renderHomeView(formErrors: [], formFieldErrors: [], formSuccess: false);
|
||||
|
||||
self::assertStringContainsString('name="csrf_token"', $html);
|
||||
self::assertStringContainsString('value="' . str_repeat('a', 64) . '"', $html);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function submitButtonHasExpectedClass(): void
|
||||
{
|
||||
$html = $this->renderHomeView(formErrors: [], formFieldErrors: [], formSuccess: false);
|
||||
self::assertStringContainsString('class="form-submit"', $html);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $formErrors
|
||||
* @param array<string, list<string>> $formFieldErrors
|
||||
*/
|
||||
private function renderHomeView(array $formErrors, array $formFieldErrors, bool $formSuccess): string
|
||||
{
|
||||
$locale = 'de';
|
||||
|
||||
$t = function (string $key) use ($locale): string {
|
||||
return $this->translations[$key] ?? '[' . $key . ']';
|
||||
};
|
||||
|
||||
$formData = [
|
||||
'fname' => 'Maria',
|
||||
'lname' => '',
|
||||
'email' => 'not-an-email',
|
||||
'phone' => '',
|
||||
'interest' => 'visit',
|
||||
'message' => '',
|
||||
];
|
||||
|
||||
$escapeContactValue = static fn(string $value): string
|
||||
=> htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
// Provide $_SESSION to the view scope via a global.
|
||||
$_SESSION = $this->session;
|
||||
|
||||
$viewPath = dirname(__DIR__, 2) . '/app/views/home/index.php';
|
||||
self::assertFileExists($viewPath);
|
||||
|
||||
ob_start();
|
||||
try {
|
||||
include $viewPath;
|
||||
} catch (\Throwable $e) {
|
||||
ob_end_clean();
|
||||
self::fail('View rendering threw: ' . $e->getMessage() . "\n" . $e->getTraceAsString());
|
||||
}
|
||||
return (string) ob_get_clean();
|
||||
}
|
||||
}
|
||||
119
tests/Views/LayoutA11yTest.php
Normal file
119
tests/Views/LayoutA11yTest.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Views;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Static a11y checks for the shared layout
|
||||
* (`app/views/layouts/main.php`). We verify the layout's *source*
|
||||
* carries the right ARIA / landmark attributes rather than spinning
|
||||
* up the full render (which would need nav, OG, structured-data,
|
||||
* server-vars, and a dozen more locals). The home view is tested
|
||||
* for content-level a11y in `HomeViewA11yTest`.
|
||||
*/
|
||||
final class LayoutA11yTest extends TestCase
|
||||
{
|
||||
private string $layoutSource;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$path = dirname(__DIR__, 2) . '/app/views/layouts/main.php';
|
||||
self::assertFileExists($path);
|
||||
$this->layoutSource = (string) file_get_contents($path);
|
||||
self::assertNotEmpty($this->layoutSource);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function htmlRootCarriesLangAttribute(): void
|
||||
{
|
||||
// The layout emits <html lang="..."> with the value rendered by PHP from
|
||||
// the $locale variable (not literally `$locale` between quotes).
|
||||
// We assert the source binds lang to the $locale variable inside PHP output.
|
||||
self::assertStringContainsString('<html lang="', $this->layoutSource);
|
||||
self::assertStringContainsString('$locale', $this->layoutSource);
|
||||
// Make sure $locale actually flows into the lang attribute (basic
|
||||
// co-occurrence check on the same <html ...> opening tag).
|
||||
self::assertMatchesRegularExpression(
|
||||
'~<html\b[^>]*\blang=[^>]*\$locale~s',
|
||||
$this->layoutSource,
|
||||
'Layout must bind <html lang> to the current locale'
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function skipLinkPointsAtMain(): void
|
||||
{
|
||||
self::assertStringContainsString('class="skip-link"', $this->layoutSource);
|
||||
self::assertStringContainsString('href="#main"', $this->layoutSource);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function mainLandmarkHasIdAndAriaLabel(): void
|
||||
{
|
||||
self::assertMatchesRegularExpression(
|
||||
'/<main\b[^>]*\bid="main"/',
|
||||
$this->layoutSource
|
||||
);
|
||||
self::assertMatchesRegularExpression(
|
||||
'/<main\b[^>]*\baria-label=/',
|
||||
$this->layoutSource
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function navLandmarkHasAriaLabel(): void
|
||||
{
|
||||
self::assertMatchesRegularExpression(
|
||||
'/<nav\b[^>]*\baria-label=/',
|
||||
$this->layoutSource
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function footerHasRoleAndLabel(): void
|
||||
{
|
||||
self::assertMatchesRegularExpression(
|
||||
'/<footer\b[^>]*\baria-label=/',
|
||||
$this->layoutSource
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function lightboxIsADialogModal(): void
|
||||
{
|
||||
self::assertStringContainsString('id="lightbox"', $this->layoutSource);
|
||||
self::assertStringContainsString('role="dialog"', $this->layoutSource);
|
||||
self::assertStringContainsString('aria-modal="true"', $this->layoutSource);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function lightboxImageCarriesAltAttribute(): void
|
||||
{
|
||||
self::assertMatchesRegularExpression(
|
||||
'/<img\b[^>]*\bid="lightboxImg"[^>]*\balt=/',
|
||||
$this->layoutSource
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function localeSwitcherHostIsAriaLabeled(): void
|
||||
{
|
||||
// The LocaleSwitcher widget uses aria-label on its <ul>.
|
||||
self::assertStringContainsString('aria-label="', $this->layoutSource);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function mobileNavToggleIsKeyboardAccessible(): void
|
||||
{
|
||||
self::assertMatchesRegularExpression(
|
||||
'/<button\b[^>]*\bclass="nav-hamburger"/',
|
||||
$this->layoutSource
|
||||
);
|
||||
self::assertStringContainsString('aria-expanded=', $this->layoutSource);
|
||||
self::assertStringContainsString('aria-controls=', $this->layoutSource);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user