feat(i18n): accessibility - per-field form errors, landmark aria-labels, tests (closes #76)

This commit is contained in:
Hermes
2026-06-04 11:04:06 +00:00
parent 0186de90ec
commit 13a25aded2
10 changed files with 421 additions and 26 deletions

View File

@@ -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' => '',
];
}
}

View File

@@ -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',

View File

@@ -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',

View File

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

View File

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

View File

@@ -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>

View File

@@ -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>