From c2eff757897bb9a3bb0d5f1967c70d26e82db0ee Mon Sep 17 00:00:00 2001 From: Pontoporeia Date: Sat, 4 Apr 2026 12:23:03 +0200 Subject: [PATCH] WCAG 3.3.1: autofocus first invalid field on add/edit form validation failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add App::flashAutofocus(fieldName) and consumeAutofocus() to the thin App helper so action handlers can identify which field caused a validation error and the form page can move browser focus directly to it on reload. Changes: - src/App.php — flashAutofocus() stores field name in _flash_autofocus session key; consumeAutofocus() drains it and returns the name (or null) - actions/formulaire.php — catch block maps exception messages to field names (auteurice, titre, synopsis, année, orientation, ap, finality, languages, tag, lien) and calls App::flashAutofocus() - actions/edit.php — catch block maps common edit errors to field names and calls App::flashAutofocus() - add.php — consumes the hint via App::consumeAutofocus() into $autofocusField; withAutofocus() helper merges autofocus=>true into $attrs for every field include; synopsis textarea gets inline autofocus - edit.php — same pattern with inline ternary merges and textarea autofocus - templates/partials/form/text-field.php — $attrs loop now emits bare attribute names (no ="...") when value === true, supporting autofocus, disabled, readonly etc. without special-casing - templates/partials/form/select-field.php — same boolean-attr support added; $attrs variable initialised to [] when caller omits it Closes WCAG 3.3.1 autofocus item in todo/04-accessibility.md. --- TODO.md | 2 ++ public/admin/actions/edit.php | 12 ++++++++ public/admin/actions/formulaire.php | 18 ++++++++++++ public/admin/add.php | 36 ++++++++++++++++-------- public/admin/edit.php | 11 ++++++-- src/App.php | 21 ++++++++++++++ templates/partials/form/select-field.php | 16 +++++++++-- templates/partials/form/text-field.php | 6 +++- todo/04-accessibility.md | 2 +- 9 files changed, 106 insertions(+), 18 deletions(-) diff --git a/TODO.md b/TODO.md index 6e47ecb..02ee247 100644 --- a/TODO.md +++ b/TODO.md @@ -11,6 +11,8 @@ Pending tasks have been split into topic files under [`todo/`](todo/README.md): ## Recently completed (this session) +- [x] WCAG 3.3.1 `autofocus` on first invalid field — `App::flashAutofocus()` / `consumeAutofocus()` added; `actions/formulaire.php` maps exception messages → field names and stores the autofocus hint in `$_SESSION['_flash_autofocus']`; `actions/edit.php` does the same; `add.php` consumes it via a `withAutofocus()` helper and injects `autofocus => true` into `$attrs` for `text-field.php` / `select-field.php` includes; `edit.php` uses inline ternary for the same; `text-field.php` and `select-field.php` partials now support boolean `true` values in `$attrs` (emit bare attribute names for `autofocus`, `required`, etc.) + - [x] `config/apropos.php` — extracted hardcoded contacts (Laurent Leprince, Xavier Gorgol, Brigitte Ledune) and credits into a config array (`contacts[]`, `credits[]`, `erg_url`); `public/apropos.php` now loops over the config with `htmlspecialchars` instead of embedding names/emails in HTML - [x] `todo/02-php-components.md` — audited and marked 8 stale items as already done: all 5 form field partials (`text-field`, `select-field`, `checkbox-list`, `file-field`, `jury-fieldset`), `admin-alert.php`/`flash-messages.php` consolidation, `RateLimit` cache dir placement, and `apropos.php` contacts extraction diff --git a/public/admin/actions/edit.php b/public/admin/actions/edit.php index 5df8d5f..d74af41 100644 --- a/public/admin/actions/edit.php +++ b/public/admin/actions/edit.php @@ -136,6 +136,18 @@ try { error_log("Edit action error: " . $e->getMessage()); App::flash('error', $e->getMessage()); + + // WCAG 3.3.1 — map error to the field that caused it so the form can autofocus it. + $msg = $e->getMessage(); + $autofocusField = null; + if (str_contains($msg, 'titre') || str_contains($msg, 'Titre')) $autofocusField = 'titre'; + elseif (str_contains($msg, 'année') || str_contains($msg, 'année')) $autofocusField = 'année'; + elseif (str_contains($msg, 'synopsis') || str_contains($msg, 'Synopsis')) $autofocusField = 'synopsis'; + elseif (str_contains($msg, 'auteur') || str_contains($msg, 'Auteur')) $autofocusField = 'auteurice'; + if ($autofocusField !== null) { + App::flashAutofocus($autofocusField); + } + header('Location: ../edit.php?id=' . $thesisId); exit(); } diff --git a/public/admin/actions/formulaire.php b/public/admin/actions/formulaire.php index 9343544..e9bcf57 100644 --- a/public/admin/actions/formulaire.php +++ b/public/admin/actions/formulaire.php @@ -322,6 +322,24 @@ try { App::flash('error', $e->getMessage()); $_SESSION['form_data'] = $_POST; + // WCAG 3.3.1 — identify which field caused the error and request autofocus. + // Mapping is based on the exception messages thrown above. + $msg = $e->getMessage(); + $autofocusField = null; + if (str_contains($msg, "Nom/Prénom/Pseudo")) $autofocusField = 'auteurice'; + elseif (str_contains($msg, "Titre du mémoire")) $autofocusField = 'titre'; + elseif (str_contains($msg, "Synopsis")) $autofocusField = 'synopsis'; + elseif (str_contains($msg, "Année invalide")) $autofocusField = 'année'; + elseif (str_contains($msg, "orientation")) $autofocusField = 'orientation'; + elseif (str_contains($msg, "Atelier Pratique")) $autofocusField = 'ap'; + elseif (str_contains($msg, "finalité")) $autofocusField = 'finality'; + elseif (str_contains($msg, "langue")) $autofocusField = 'languages'; + elseif (str_contains($msg, "mots-clés")) $autofocusField = 'tag'; + elseif (str_contains($msg, "Lien URL")) $autofocusField = 'lien'; + if ($autofocusField !== null) { + App::flashAutofocus($autofocusField); + } + // Redirect back to form with preserved data header('Location: ../add.php'); exit(); diff --git a/public/admin/add.php b/public/admin/add.php index aaed1f6..a2ca581 100644 --- a/public/admin/add.php +++ b/public/admin/add.php @@ -24,10 +24,23 @@ try { die("Erreur lors du chargement du formulaire."); } -$formData = $_SESSION["form_data"] ?? []; -unset($_SESSION["form_data"]); +$formData = $_SESSION['form_data'] ?? []; +unset($_SESSION['form_data']); +$autofocusField = App::consumeAutofocus(); // Flash error consumed by the flash-messages partial below. +/** + * Merge autofocus into the $attrs array for a given field. + * Only adds the attribute when $autofocusField matches $fieldName. + */ +function withAutofocus(string $fieldName, array $attrs = []): array { + global $autofocusField; + if ($autofocusField === $fieldName) { + $attrs['autofocus'] = true; + } + return $attrs; +} + function old($key, $default = "") { global $formData; return isset($formData[$key]) ? htmlspecialchars($formData[$key]) : $default; @@ -50,9 +63,9 @@ function wasSelected($key, $value) {
"> - + - 'name']; include APP_ROOT . '/templates/partials/form/text-field.php'; ?> + 'name']); include APP_ROOT . '/templates/partials/form/text-field.php'; ?> 'email']; include APP_ROOT . '/templates/partials/form/text-field.php'; ?> @@ -61,34 +74,35 @@ function wasSelected($key, $value) { $name = 'année'; $label = 'Année :'; $value = old('année'); $required = true; $type = 'number'; $placeholder = date('Y'); - $attrs = ['min' => 2000, 'max' => date('Y') + 1]; + $attrs = withAutofocus('année', ['min' => 2000, 'max' => date('Y') + 1]); include APP_ROOT . '/templates/partials/form/text-field.php'; ?> - + - + - + - +
+ rows="7" required + >
- + diff --git a/public/admin/edit.php b/public/admin/edit.php index a6a3d75..ae97e10 100644 --- a/public/admin/edit.php +++ b/public/admin/edit.php @@ -53,6 +53,9 @@ try { // Set page title for header $pageTitle = "Éditer TFE - " . htmlspecialchars($thesis['title']); + // WCAG 3.3.1 — consume the autofocus hint stored by the edit action on validation failure. + $autofocusField = App::consumeAutofocus(); + } catch (Exception $e) { error_log("Error loading edit page: " . $e->getMessage()); die("Erreur lors du chargement: " . $e->getMessage()); @@ -70,12 +73,13 @@ try { - 'name']; include APP_ROOT . '/templates/partials/form/text-field.php'; ?> + 'name'], $autofocusField === 'auteurice' ? ['autofocus' => true] : []); include APP_ROOT . '/templates/partials/form/text-field.php'; ?> 'email']; include APP_ROOT . '/templates/partials/form/text-field.php'; ?> true] : []; include APP_ROOT . '/templates/partials/form/text-field.php'; ?> @@ -129,13 +133,14 @@ try { - + true] : []; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
- +
diff --git a/src/App.php b/src/App.php index d72c94d..74487ea 100644 --- a/src/App.php +++ b/src/App.php @@ -83,6 +83,26 @@ class App $_SESSION["_flash_{$type}"] = $message; } + /** + * Store the name of the field that should receive autofocus after a + * validation failure (WCAG 3.3.1). + */ + public static function flashAutofocus(string $fieldName): void + { + $_SESSION['_flash_autofocus'] = $fieldName; + } + + /** + * Consume and return the autofocus field name, then clear it. + * Returns null when no autofocus hint is present. + */ + public static function consumeAutofocus(): ?string + { + $field = $_SESSION['_flash_autofocus'] ?? null; + unset($_SESSION['_flash_autofocus']); + return $field; + } + /** * Consume and return flash messages, then clear them from the session. * @@ -117,6 +137,7 @@ class App $_SESSION['form_error'] ); + // Note: autofocus is consumed separately via consumeAutofocus(). return ['error' => $error, 'success' => $success]; } diff --git a/templates/partials/form/select-field.php b/templates/partials/form/select-field.php index 6b95a1a..24ec4b6 100644 --- a/templates/partials/form/select-field.php +++ b/templates/partials/form/select-field.php @@ -20,12 +20,24 @@ $required = $required ?? false; $placeholder = array_key_exists('placeholder', get_defined_vars()) ? $placeholder : ''; $id = $id ?? $name; $hint = $hint ?? null; +$attrs = $attrs ?? []; ?>
+ $v) { + if ($v === true) { + $selectAttrStr .= ' ' . htmlspecialchars($k); + } else { + $selectAttrStr .= ' ' . htmlspecialchars($k) . '="' . htmlspecialchars((string)$v) . '"'; + } +} +?>