Multi-Language MVP: 4 Locales (DE/EN/UK/RU) mit SSR + UI + Tests #79

Merged
greggy merged 27 commits from feature/multilanguage-mvp into main 2026-06-05 23:49:39 +02:00
10 changed files with 421 additions and 26 deletions
Showing only changes of commit 13a25aded2 - Show all commits

View File

@@ -34,13 +34,23 @@ class HomeController extends Controller
// ── Pull flashed state ──────────────────────────────────────── // ── Pull flashed state ────────────────────────────────────────
$formSuccess = !empty($_SESSION['form_success']); $formSuccess = !empty($_SESSION['form_success']);
$formErrors = $_SESSION['form_errors'] ?? []; $formErrors = $_SESSION['form_errors'] ?? [];
$formFieldErrors = $_SESSION['form_field_errors'] ?? [];
$formData = $_SESSION['form_data'] ?? null; $formData = $_SESSION['form_data'] ?? null;
unset($_SESSION['form_success'], $_SESSION['form_errors'], $_SESSION['form_data']); unset(
$_SESSION['form_success'],
$_SESSION['form_errors'],
$_SESSION['form_field_errors'],
$_SESSION['form_data'],
);
if ($formSuccess) { if ($formSuccess) {
$formData = ['fname' => '', 'lname' => '', 'email' => '', 'phone' => '', 'interest' => 'visit', 'message' => '']; $formData = self::emptyFormData();
$formFieldErrors = [];
} elseif (!is_array($formData)) { } 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 ──────────────────────────────────────────────── // ── CSRF token ────────────────────────────────────────────────
@@ -72,17 +82,19 @@ class HomeController extends Controller
exit; exit;
} }
// Per-field errors enable aria-invalid + aria-describedby.
$formFieldErrors = [];
if ($formData['fname'] === '') { if ($formData['fname'] === '') {
$formErrors[] = 'form.error.fname_required'; $formFieldErrors['fname'][] = 'form.error.fname_required';
} }
if ($formData['lname'] === '') { if ($formData['lname'] === '') {
$formErrors[] = 'form.error.lname_required'; $formFieldErrors['lname'][] = 'form.error.lname_required';
} }
if ($formData['email'] === '' || !filter_var($formData['email'], FILTER_VALIDATE_EMAIL)) { if ($formData['email'] === '' || !filter_var($formData['email'], FILTER_VALIDATE_EMAIL)) {
$formErrors[] = 'form.error.email_invalid'; $formFieldErrors['email'][] = 'form.error.email_invalid';
} }
if ($formData['message'] === '') { if ($formData['message'] === '') {
$formErrors[] = 'form.error.message_required'; $formFieldErrors['message'][] = 'form.error.message_required';
} }
if ($containsHeaderInjection($formData['email']) || $containsHeaderInjection($formData['fname'] . ' ' . $formData['lname'])) { if ($containsHeaderInjection($formData['email']) || $containsHeaderInjection($formData['fname'] . ' ' . $formData['lname'])) {
$formErrors[] = 'form.error.header_injection'; $formErrors[] = 'form.error.header_injection';
@@ -98,7 +110,7 @@ class HomeController extends Controller
$formErrors[] = 'form.error.rate_limit'; $formErrors[] = 'form.error.rate_limit';
} }
if (empty($formErrors)) { if (empty($formErrors) && empty($formFieldErrors)) {
$interestKey = self::INTEREST_KEYS[$formData['interest']] ?? 'form.interest.visit'; $interestKey = self::INTEREST_KEYS[$formData['interest']] ?? 'form.interest.visit';
$interestLabel = I18n::t($interestKey, [], $locale); $interestLabel = I18n::t($interestKey, [], $locale);
@@ -130,6 +142,7 @@ class HomeController extends Controller
} }
$_SESSION['form_errors'] = $formErrors; $_SESSION['form_errors'] = $formErrors;
$_SESSION['form_field_errors'] = $formFieldErrors;
$_SESSION['form_data'] = $formData; $_SESSION['form_data'] = $formData;
header('Location: /#kontakt'); header('Location: /#kontakt');
exit; exit;
@@ -177,10 +190,26 @@ class HomeController extends Controller
$this->render('home/index', [ $this->render('home/index', [
'formSuccess' => $formSuccess, 'formSuccess' => $formSuccess,
'formErrors' => $formErrors, 'formErrors' => $formErrors,
'formFieldErrors' => $formFieldErrors,
'formData' => $formData, 'formData' => $formData,
'interestKeys' => self::INTEREST_KEYS, 'interestKeys' => self::INTEREST_KEYS,
'escapeContactValue' => $escapeContactValue, 'escapeContactValue' => $escapeContactValue,
'structuredData' => $structuredData, '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' => '',
];
}
} }

View File

@@ -178,6 +178,8 @@ return [
'footer.address' => 'Bahnhofstraße 10 · Schleusingen', 'footer.address' => 'Bahnhofstraße 10 · Schleusingen',
'footer.imprint' => 'Impressum', 'footer.imprint' => 'Impressum',
'footer.privacy' => 'Datenschutz', 'footer.privacy' => 'Datenschutz',
'footer.aria' => 'Fußbereich',
'a11y.main' => 'Hauptinhalt',
// ─── Lightbox ──────────────────────────────────────────────────────── // ─── Lightbox ────────────────────────────────────────────────────────
'lightbox.aria' => 'Bildansicht', 'lightbox.aria' => 'Bildansicht',

View File

@@ -163,6 +163,8 @@ return [
'footer.address' => 'Bahnhofstraße 10 · Schleusingen', 'footer.address' => 'Bahnhofstraße 10 · Schleusingen',
'footer.imprint' => 'Imprint', 'footer.imprint' => 'Imprint',
'footer.privacy' => 'Privacy policy', 'footer.privacy' => 'Privacy policy',
'footer.aria' => 'Footer',
'a11y.main' => 'Main content',
'lightbox.aria' => 'Image view', 'lightbox.aria' => 'Image view',
'lightbox.close' => 'Close image view', 'lightbox.close' => 'Close image view',

View File

@@ -163,6 +163,8 @@ return [
'footer.address' => 'Bahnhofstraße 10 · Шлайзинген', 'footer.address' => 'Bahnhofstraße 10 · Шлайзинген',
'footer.imprint' => 'Импрессум', 'footer.imprint' => 'Импрессум',
'footer.privacy' => 'Политика конфиденциальности', 'footer.privacy' => 'Политика конфиденциальности',
'footer.aria' => 'Подвал сайта',
'a11y.main' => 'Основное содержимое',
'lightbox.aria' => 'Просмотр изображения', 'lightbox.aria' => 'Просмотр изображения',
'lightbox.close' => 'Закрыть просмотр изображения', 'lightbox.close' => 'Закрыть просмотр изображения',

View File

@@ -163,6 +163,8 @@ return [
'footer.address' => 'Bahnhofstraße 10 · Шлайзінген', 'footer.address' => 'Bahnhofstraße 10 · Шлайзінген',
'footer.imprint' => 'Імпресум', 'footer.imprint' => 'Імпресум',
'footer.privacy' => 'Політика конфіденційності', 'footer.privacy' => 'Політика конфіденційності',
'footer.aria' => 'Нижній колонтитул',
'a11y.main' => 'Головний вміст',
'lightbox.aria' => 'Перегляд зображення', 'lightbox.aria' => 'Перегляд зображення',
'lightbox.close' => 'Закрити перегляд зображення', 'lightbox.close' => 'Закрити перегляд зображення',

View File

@@ -275,12 +275,20 @@ $gridItems = [
<div class="form-field"> <div class="form-field">
<label for="fname"><?= htmlspecialchars($t('contact.fname'), ENT_QUOTES) ?></label> <label for="fname"><?= htmlspecialchars($t('contact.fname'), ENT_QUOTES) ?></label>
<input type="text" id="fname" name="fname" required maxlength="80" autocomplete="given-name" <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>
<div class="form-field"> <div class="form-field">
<label for="lname"><?= htmlspecialchars($t('contact.lname'), ENT_QUOTES) ?></label> <label for="lname"><?= htmlspecialchars($t('contact.lname'), ENT_QUOTES) ?></label>
<input type="text" id="lname" name="lname" required maxlength="80" autocomplete="family-name" <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>
@@ -288,7 +296,11 @@ $gridItems = [
<div class="form-field"> <div class="form-field">
<label for="email"><?= htmlspecialchars($t('contact.email'), ENT_QUOTES) ?></label> <label for="email"><?= htmlspecialchars($t('contact.email'), ENT_QUOTES) ?></label>
<input type="email" id="email" name="email" required maxlength="120" autocomplete="email" <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>
<div class="form-field"> <div class="form-field">
<label for="phone"><?= htmlspecialchars($t('contact.phone'), ENT_QUOTES) ?></label> <label for="phone"><?= htmlspecialchars($t('contact.phone'), ENT_QUOTES) ?></label>
@@ -319,7 +331,12 @@ $gridItems = [
<div class="form-field"> <div class="form-field">
<label for="message"><?= htmlspecialchars($t('contact.message'), ENT_QUOTES) ?></label> <label for="message"><?= htmlspecialchars($t('contact.message'), ENT_QUOTES) ?></label>
<textarea id="message" name="message" required rows="6" maxlength="2000" <textarea id="message" name="message" required rows="6" maxlength="2000"
placeholder="<?= htmlspecialchars($t('contact.message'), ENT_QUOTES) ?>"><?= $escapeContactValue($formData['message'] ?? '') ?></textarea> 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> </div>
<button type="submit" class="form-submit"><?= htmlspecialchars($t('contact.submit'), ENT_QUOTES) ?></button> <button type="submit" class="form-submit"><?= htmlspecialchars($t('contact.submit'), ENT_QUOTES) ?></button>

View File

@@ -114,11 +114,11 @@ $navItems = [
<div id="navMobile" class="nav-mobile-overlay" hidden></div> <div id="navMobile" class="nav-mobile-overlay" hidden></div>
</nav> </nav>
<main id="main" tabindex="-1"> <main id="main" tabindex="-1" aria-label="<?= htmlspecialchars($t('a11y.main'), ENT_QUOTES) ?>">
<?= $content ?> <?= $content ?>
</main> </main>
<footer> <footer aria-label="<?= htmlspecialchars($t('footer.aria'), ENT_QUOTES) ?>">
<div class="footer-logo"> <div class="footer-logo">
<span class="logo-icon" aria-hidden="true">🏠</span> <span class="logo-icon" aria-hidden="true">🏠</span>
<span><?= htmlspecialchars($t('footer.address'), ENT_QUOTES) ?></span> <span><?= htmlspecialchars($t('footer.address'), ENT_QUOTES) ?></span>

View File

@@ -1435,3 +1435,23 @@ nav.scrolled .locale-switcher__option.is-current {
white-space: nowrap; white-space: nowrap;
border: 0; 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;
}

View 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();
}
}

View 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);
}
}