Add field-level aria-errormessage, aria-invalid, and aria-describedby across the TFE form

WCAG 3.3.1 (Error Identification): failing fields now get
aria-errormessage pointing to the flash-error container and
aria-invalid="true". WCAG 3.3.3 (Error Suggestion): <small>
hint text on inputs, selects, and file fields is now linked via
aria-describedby (always, not just on error).

Changes:
- text-field.php, select-field.php, checkbox-list.php: accept
  $errorFieldName; add aria-errormessage/aria-invalid on match;
  add id to <small> and aria-describedby on the control
- fieldset-tfe-info.php: aria-invalid on synopsis textarea
- fichiers-fragment.php: aria-describedby on cover, note
  d'intention, TFE, annexes, and website inputs; aria-invalid
  on format checkboxes when error matches 'formats'
- form.php: id="flash-error" + tabindex="-1" on flash-error
  div; accept $errorFieldName from callers
- admin/add.php: set $errorFieldName, wire $withAutofocusFn
  (was identity default)
- admin/edit.php: set $errorFieldName
- partage/index.php: consume autofocus field, wire autofocus
  function, add App::flashAutofocus() in submit catch block

Also fixes WCAG standards issue: removed invalid 'required'
HTML attribute from <fieldset> elements in checkbox-list.php
and fichiers-fragment.php (only aria-required stays). Added
role="group" for explicit ARIA semantics.
This commit is contained in:
Pontoporeia
2026-06-11 10:22:06 +02:00
parent c0ba99e861
commit e17246c850
11 changed files with 111 additions and 35 deletions

File diff suppressed because one or more lines are too long

20
TODO.md
View File

@@ -35,10 +35,16 @@ Reference: Assessment against progressive-enhancement / WCAG-AA / "never lose da
**Current state:** Flash error divs have `role="alert"` but individual fields are never linked to their error via `aria-errormessage`. The `autofocusFieldForError()` mechanism focuses the field after a validation redirect but does not announce the error to screen readers. Help `<small>` text is not linked via `aria-describedby`.
**To do:**
- [ ] Extend `text-field.php`, `select-field.php`, `checkbox-list.php` partials to accept optional `$error` and `$errorId` variables
- [ ] When `$error` is set, add `aria-errormessage="$errorId"` and `aria-invalid="true"` on the input
- [ ] On validation redirect, populate per-field error IDs so each failing field references the flash error container
- [ ] Add `aria-describedby` linking each `<small>` hint to its input (always, not just on error)
- [x] Extend `text-field.php`, `select-field.php`, `checkbox-list.php` partials to accept optional `$errorFieldName` variable
- [x] When `$errorFieldName` matches the field, add `aria-errormessage="flash-error"` and `aria-invalid="true"` on the input
- [x] On validation redirect, populate `$errorFieldName` from `App::consumeAutofocus()` so each failing field references the flash error container
- [x] Add `aria-describedby` linking each `<small>` hint to its input (always, not just on error)
- [x] Give flash-error div `id="flash-error"` and `tabindex="-1"` for programmatic reference
- [x] Wire `App::flashAutofocus()` into partage submit catch block (was missing)
- [x] Wire `$withAutofocusFn` in admin add template (was defaulting to identity)
- [x] Apply `aria-invalid` + `aria-errormessage` on synopsis textarea (not in text-field partial)
- [x] Apply `aria-describedby` on file inputs in `fichiers-fragment.php`
- [x] Apply format checkboxes `aria-invalid` support in `fichiers-fragment.php`
- [ ] Test with VoiceOver and NVDA on the full add/edit/partage form flows
### 2. No-JS file uploads silently fail (data loss)
@@ -103,9 +109,9 @@ Reference: Assessment against progressive-enhancement / WCAG-AA / "never lose da
**Current state:** `checkbox-list.php` and `fichiers-fragment.php` put `required aria-required="true"` on `<fieldset>` elements. The `required` attribute is not valid on `<fieldset>`. Screen readers may not interpret this correctly.
**To do:**
- [ ] Move `required` + `aria-required` to individual checkboxes inside the group
- [ ] For checkbox groups (languages, formats): mark at least one checkbox as `required`, keep `aria-required="true"` on the fieldset (without the HTML `required` attribute)
- [ ] Add `role="group"` on the fieldset for explicit ARIA semantics
- [x] Remove `required` attribute from `<fieldset>` in `checkbox-list.php` — keep `aria-required="true"` only
- [x] Remove `required` attribute from `<fieldset>` in `fichiers-fragment.php` — keep `aria-required="true"` only
- [x] Add `role="group"` on both `<fieldset>` elements for explicit ARIA semantics
### 9. Refactor partage form page wrapper to a template

View File

@@ -338,10 +338,13 @@ function renderShareLinkForm(string $slug, array $link): void
$pageTitle = 'Soumettre un TFE';
$isVerified = !empty($_SESSION['share_verified_' . $slug]);
// WCAG 3.3.1: which field has a validation error (set by submit handler via App::flashAutofocus)
$errorFieldName = App::consumeAutofocus();
// Build old()-compatible callable from $formData (share forms use the array variant).
$shareOldFn = fn(string $key, string $default = '') => old($formData, $key, $default);
// No autofocus in the share form - identity function.
$shareWithAutofocusFn = fn(string $field, array $attrs = []) => $attrs;
// Autofocus function for the share form
$shareWithAutofocusFn = fn(string $field, array $attrs = []) => ($errorFieldName === $field) ? ($attrs + ['autofocus' => true]) : $attrs;
// Load all form help blocks in one query.
$helpBlocks = Database::getInstance()->getAllFormHelpBlocks();
@@ -647,6 +650,12 @@ function handleShareLinkSubmission(string $slug): void
storePrimedFiles($slug);
$_SESSION[$shareCsrfKey] = bin2hex(random_bytes(32)); // Regenerate token
// WCAG 3.3.1: autofocus the failing field after redirect
$autofocusField = ThesisCreateController::autofocusFieldForError($e->getMessage());
if ($autofocusField !== null) {
App::flashAutofocus($autofocusField);
}
// Redirect back to the form
header('Location: /partage/' . urlencode($slug));
exit();

View File

@@ -6,6 +6,11 @@
$mode = 'add';
$formAction = 'actions/formulaire.php';
$hiddenFields = '<input type="hidden" name="csrf_token" value="' . htmlspecialchars($_SESSION["csrf_token"]) . '">';
$errorFieldName = $autofocusField ?? null;
$withAutofocusFn = function (string $field, array $attrs = []) use ($autofocusField) {
if ($autofocusField === $field) $attrs['autofocus'] = true;
return $attrs;
};
$synopsisExtra = '';

View File

@@ -33,6 +33,7 @@
$formAction = '/admin/actions/edit.php';
$hiddenFields = '<input type="hidden" name="csrf_token" value="' . htmlspecialchars($_SESSION['csrf_token']) . '">'
. '<input type="hidden" name="thesis_id" value="' . $thesisId . '">';
$errorFieldName = $autofocusField ?? null;
$synopsisExtra = '';
$formData = $editFormData;

View File

@@ -18,6 +18,7 @@
* string $hxTarget — optional hx-target CSS selector for HTMX swap
* string $hxSwap — optional hx-swap value; default 'outerHTML'
* string $hxInclude — optional hx-include selector; default 'this'
* string|null $errorFieldName — when set and matches $name, adds aria-invalid on checkboxes
*/
$checked = $checked ?? [];
@@ -26,11 +27,17 @@ $hxPost = $hxPost ?? '';
$hxTarget = $hxTarget ?? '';
$hxSwap = $hxSwap ?? 'outerHTML';
$hxInclude = $hxInclude ?? 'this';
$errorFieldName = $errorFieldName ?? null;
$ariaInvalid = ($errorFieldName === $name) ? ' aria-invalid="true" aria-errormessage="flash-error"' : '';
// aria-describedby only when there's an error (no hint text for checkbox groups)
$ariaDescribedBy = ($errorFieldName === $name) ? ' aria-describedby="flash-error"' : '';
?>
<div>
<span class="admin-row-label"><?= isset($labelHtml) ? $labelHtml : (htmlspecialchars($label) . ($required ? ' <span class="asterisk">*</span>' : '')) ?></span>
<fieldset class="admin-checkbox-group"
<?= $required ? ' required aria-required="true"' : '' ?>
role="group"
<?= $required ? ' aria-required="true"' : '' ?>
<?php if ($hxPost !== ''): ?>
hx-post="<?= htmlspecialchars($hxPost) ?>"
hx-target="<?= htmlspecialchars($hxTarget) ?>"
@@ -46,7 +53,9 @@ $hxInclude = $hxInclude ?? 'this';
<input type="checkbox"
name="<?= htmlspecialchars($name) ?>[]"
value="<?= htmlspecialchars((string)$opt['id']) ?>"
<?= in_array((string)$opt['id'], array_map('strval', $checked)) ? 'checked' : '' ?>>
<?= in_array((string)$opt['id'], array_map('strval', $checked)) ? 'checked' : '' ?>
<?= $ariaInvalid ?>
<?= $ariaDescribedBy ?>>
<?= htmlspecialchars($opt['name']) ?>
</label>
</li>
@@ -55,4 +64,4 @@ $hxInclude = $hxInclude ?? 'this';
</fieldset>
</div>
<?php
unset($checked, $hxPost, $hxTarget, $hxSwap, $hxInclude);
unset($checked, $hxPost, $hxTarget, $hxSwap, $hxInclude, $ariaInvalid, $ariaDescribedBy, $errorFieldName);

View File

@@ -35,6 +35,7 @@ $selectedFormats = isset($_POST['formats']) && is_array($_POST['formats'])
$adminMode = ($_POST['admin_mode'] ?? '0') === '1';
$editMode = ($_POST['edit_mode'] ?? '0') === '1';
$errorFieldName = $errorFieldName ?? null;
// TFE file is optional when format is Site web (3), Performance (4) or Installation (6)
$noTfeFileFormats = [3, 4, 6];
@@ -53,8 +54,8 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? '');
<legend>Format(s)</legend>
<div>
<span class="admin-row-label">Format(s) du TFE :<?= !$adminMode ? ' <span class="asterisk">*</span>' : '' ?></span>
<fieldset class="admin-checkbox-group"
<?= !$adminMode ? 'required aria-required="true"' : '' ?> >
<fieldset class="admin-checkbox-group" role="group"
<?= !$adminMode ? 'aria-required="true"' : '' ?> >
<legend class="sr-only">Format(s) du TFE</legend>
<ul>
<?php foreach ($allFormats as $opt): ?>
@@ -63,7 +64,8 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? '');
<input type="checkbox"
name="formats[]"
value="<?= htmlspecialchars((string)$opt['id']) ?>"
<?= in_array((int)$opt['id'], $selectedFormats, true) ? 'checked' : '' ?>>
<?= in_array((int)$opt['id'], $selectedFormats, true) ? 'checked' : '' ?>
<?= ($errorFieldName === 'formats') ? 'aria-invalid="true" aria-errormessage="flash-error" aria-describedby="flash-error"' : '' ?>>
<?= htmlspecialchars($opt['name']) ?>
</label>
</li>
@@ -85,8 +87,9 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? '');
name="queue_file[cover][]"
class="tfe-file-picker tfe-file-picker--single"
data-queue-type="cover"
data-existing-files='<?= htmlspecialchars(json_encode($existingFilesJsonForCover ?? []), ENT_QUOTES) ?>'>
<small>JPG, PNG ou WEBP. Format 4:3 recommandé. Max 20 MB.</small>
data-existing-files='<?= htmlspecialchars(json_encode($existingFilesJsonForCover ?? []), ENT_QUOTES) ?>'
aria-describedby="couverture-hint">
<small id="couverture-hint">JPG, PNG ou WEBP. Format 4:3 recommandé. Max 20 MB.</small>
</div>
<?php if ($editMode): ?>
<button type="button" class="btn btn--sm btn--ghost file-browser-trigger"
@@ -111,8 +114,10 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? '');
class="tfe-file-picker tfe-file-picker--single"
data-queue-type="note_intention"
data-existing-files='<?= htmlspecialchars(json_encode($existingFilesJsonForNoteIntention ?? []), ENT_QUOTES) ?>'
<?= !$adminMode ? 'required' : '' ?>>
<small>PDF uniquement. Max 100 MB. Si votre fichier est trop lourd, compressez-le avec <a href="https://www.bentopdf.com" target="_blank" rel="noopener">bentopdf.com</a>.</small>
<?= !$adminMode ? 'required' : '' ?>
<?= ($errorFieldName === 'note_intention') ? 'aria-invalid="true" aria-errormessage="flash-error"' : '' ?>
aria-describedby="note_intention-hint<?= ($errorFieldName === 'note_intention') ? ' flash-error' : '' ?>">
<small id="note_intention-hint">PDF uniquement. Max 100 MB. Si votre fichier est trop lourd, compressez-le avec <a href="https://www.bentopdf.com" target="_blank" rel="noopener">bentopdf.com</a>.</small>
</div>
<?php if ($editMode): ?>
<button type="button" class="btn btn--sm btn--ghost file-browser-trigger"
@@ -138,8 +143,10 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? '');
class="tfe-file-picker"
data-queue-type="tfe"
<?= (!$adminMode && !$tfeFileOptional) ? 'required' : '' ?>
data-existing-files='<?= htmlspecialchars(json_encode($existingFilesJsonForTfe ?? []), ENT_QUOTES) ?>'>
<small class="admin-file-hint">
data-existing-files='<?= htmlspecialchars(json_encode($existingFilesJsonForTfe ?? []), ENT_QUOTES) ?>'
<?= ($errorFieldName === 'tfe-files-input') ? 'aria-invalid="true" aria-errormessage="flash-error"' : '' ?>
aria-describedby="tfe-files-hint<?= ($errorFieldName === 'tfe-files-input') ? ' flash-error' : '' ?>">
<small id="tfe-files-hint" class="admin-file-hint">
Glissez pour réordonner.
<br>
<br>
@@ -175,8 +182,9 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? '');
multiple
class="tfe-file-picker"
data-queue-type="annexe"
data-existing-files='<?= htmlspecialchars(json_encode($existingFilesJsonForAnnexe ?? []), ENT_QUOTES) ?>'>
<small class="admin-file-hint">PDF ou archives ZIP/TAR. Max 1 GB. Glissez pour réordonner.</small>
data-existing-files='<?= htmlspecialchars(json_encode($existingFilesJsonForAnnexe ?? []), ENT_QUOTES) ?>'
aria-describedby="annexe-files-hint">
<small id="annexe-files-hint" class="admin-file-hint">PDF ou archives ZIP/TAR. Max 1 GB. Glissez pour réordonner.</small>
</div>
<?php if ($editMode): ?>
<button type="button" class="btn btn--sm btn--ghost file-browser-trigger"
@@ -199,8 +207,9 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? '');
<div class="admin-file-input">
<input type="url" id="website_url" name="website_url"
value="<?= $websiteUrl ?>"
placeholder="https://mon-tfe.erg.be">
<small>Le TFE sera affiché comme un site embarqué sur sa page publique.</small>
placeholder="https://mon-tfe.erg.be"
aria-describedby="website_url-hint">
<small id="website_url-hint">Le TFE sera affiché comme un site embarqué sur sa page publique.</small>
</div>
</div>

View File

@@ -19,6 +19,7 @@ $allowedObjet = $allowedObjet ?? [];
$formData = $formData ?? [];
$synopsisExtra = $synopsisExtra ?? '';
$adminMode = $adminMode ?? false;
$errorFieldName = $errorFieldName ?? null;
?>
<fieldset>
<legend>Informations du TFE</legend>
@@ -72,7 +73,8 @@ $adminMode = $adminMode ?? false;
<label for="synopsis">Synopsis :<?= $adminMode ? '' : ' <span class="asterisk">*</span>' ?></label>
<?= $synopsisExtra ?>
<textarea id="synopsis" name="synopsis" rows="7" <?= $adminMode ? '' : 'required' ?>
<?= ($withAutofocusFn('synopsis')['autofocus'] ?? false) ? 'autofocus' : '' ?>><?= $oldFn('synopsis') ?></textarea>
<?= ($withAutofocusFn('synopsis')['autofocus'] ?? false) ? 'autofocus' : '' ?>
<?= ($errorFieldName === 'synopsis') ? 'aria-invalid="true" aria-errormessage="flash-error" aria-describedby="flash-error"' : '' ?>><?= $oldFn('synopsis') ?></textarea>
</div>
</fieldset>
<?php

View File

@@ -95,6 +95,9 @@ $filesMode = $filesMode ?? 'add';
$existingWebsiteUrl = $existingWebsiteUrl ?? '';
$existingWebsiteLabel = $existingWebsiteLabel ?? '';
$checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? [];
// WCAG 3.3.1: which field has a validation error (set by caller from App::consumeAutofocus())
$errorFieldName = $errorFieldName ?? null;
?>
<?php if ($showIntroHelp && isset($helpFn)): ?>
@@ -119,7 +122,7 @@ $checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? [];
);
?>
<?php if ($flashError): ?>
<div class="flash-error" role="alert"><?= htmlspecialchars(
<div class="flash-error" id="flash-error" role="alert" tabindex="-1"><?= htmlspecialchars(
$flashError,
) ?></div>
<?php endif; ?>

View File

@@ -14,6 +14,7 @@
* set to null to suppress the empty option entirely
* string|null $id — override the id attribute (defaults to $name)
* string|null $hint — optional hint shown in <small> below the select
* string|null $errorFieldName — when set and matches $name, adds aria-invalid + aria-errormessage
*/
$required = $required ?? false;
@@ -21,6 +22,18 @@ $placeholder = array_key_exists('placeholder', get_defined_vars()) ? $placeholde
$id = $id ?? $name;
$hint = $hint ?? null;
$attrs = $attrs ?? [];
$errorFieldName = $errorFieldName ?? null;
// Build hint id for aria-describedby
$hintId = ($hint !== null) ? ($id . '-hint') : null;
// Build describedby string (hint + error)
$describedBy = [];
if ($hintId !== null) $describedBy[] = $hintId;
if ($errorFieldName === $name) $describedBy[] = 'flash-error';
$ariaDescribedBy = !empty($describedBy) ? ' aria-describedby="' . implode(' ', $describedBy) . '"' : '';
$ariaInvalid = ($errorFieldName === $name) ? ' aria-invalid="true" aria-errormessage="flash-error"' : '';
?>
<div>
<label for="<?= htmlspecialchars($id) ?>"><?= htmlspecialchars($label) ?><?= $required ? ' <span class="asterisk">*</span>' : '' ?></label>
@@ -37,7 +50,9 @@ foreach ($attrs as $k => $v) {
<select id="<?= htmlspecialchars($id) ?>"
name="<?= htmlspecialchars($name) ?>"
<?= $required ? 'required' : '' ?>
<?= $selectAttrStr ?>>
<?= $selectAttrStr ?>
<?= $ariaInvalid ?>
<?= $ariaDescribedBy ?>>
<?php if ($placeholder !== null): ?>
<option value=""><?= htmlspecialchars($placeholder) ?></option>
<?php endif; ?>
@@ -54,8 +69,8 @@ foreach ($attrs as $k => $v) {
<?php endforeach; ?>
</select>
<?php if ($hint): ?>
<small><?= htmlspecialchars($hint) ?></small>
<small id="<?= htmlspecialchars($hintId) ?>"><?= htmlspecialchars($hint) ?></small>
<?php endif; ?>
</div>
<?php
unset($required, $placeholder, $id, $hint, $attrs, $selectAttrStr, $k, $v);
unset($required, $placeholder, $id, $hint, $attrs, $selectAttrStr, $k, $v, $hintId, $describedBy, $ariaDescribedBy, $ariaInvalid, $errorFieldName);

View File

@@ -12,6 +12,7 @@
* string|null $hint — optional hint shown in <small> below the input
* string|null $id — override the id attribute (defaults to $name)
* array $attrs — extra HTML attributes as key=>value pairs (e.g. min/max for number)
* string|null $errorFieldName — when set and matches $name, adds aria-invalid + aria-errormessage
*
* The partial does NOT call htmlspecialchars on $value — the caller is responsible.
*/
@@ -22,6 +23,16 @@ $placeholder = $placeholder ?? '';
$hint = $hint ?? null;
$id = $id ?? $name;
$attrs = $attrs ?? [];
$errorFieldName = $errorFieldName ?? null;
// Build hint id for aria-describedby
$hintId = ($hint !== null) ? ($id . '-hint') : null;
// Build describedby string (hint + error)
$describedBy = [];
if ($hintId !== null) $describedBy[] = $hintId;
if ($errorFieldName === $name) $describedBy[] = 'flash-error';
$ariaDescribedBy = !empty($describedBy) ? ' aria-describedby="' . implode(' ', $describedBy) . '"' : '';
$attrStr = '';
foreach ($attrs as $k => $v) {
@@ -31,6 +42,8 @@ foreach ($attrs as $k => $v) {
$attrStr .= ' ' . htmlspecialchars($k) . '="' . htmlspecialchars((string)$v) . '"';
}
}
$ariaInvalid = ($errorFieldName === $name) ? ' aria-invalid="true" aria-errormessage="flash-error"' : '';
?>
<div>
<label for="<?= htmlspecialchars($id) ?>"><?= htmlspecialchars($label) ?><?= $required ? ' <span class="asterisk">*</span>' : '' ?></label>
@@ -42,8 +55,10 @@ foreach ($attrs as $k => $v) {
value="<?= $value ?>"
<?= $required ? 'required' : '' ?>
<?= $placeholder ? 'placeholder="' . htmlspecialchars($placeholder) . '"' : '' ?>
<?= $attrStr ?>>
<small><?= htmlspecialchars($hint) ?></small>
<?= $attrStr ?>
<?= $ariaInvalid ?>
<?= $ariaDescribedBy ?>>
<small id="<?= htmlspecialchars($hintId) ?>"><?= htmlspecialchars($hint) ?></small>
</div>
<?php else: ?>
<input type="<?= htmlspecialchars($type) ?>"
@@ -52,9 +67,11 @@ foreach ($attrs as $k => $v) {
value="<?= $value ?>"
<?= $required ? 'required' : '' ?>
<?= $placeholder ? 'placeholder="' . htmlspecialchars($placeholder) . '"' : '' ?>
<?= $attrStr ?>>
<?= $attrStr ?>
<?= $ariaInvalid ?>
<?= $ariaDescribedBy ?>>
<?php endif; ?>
</div>
<?php
// Reset consumed variables so includes in a loop don't bleed state.
unset($type, $required, $placeholder, $hint, $id, $attrs, $attrStr, $k, $v);
unset($type, $required, $placeholder, $hint, $id, $attrs, $attrStr, $k, $v, $hintId, $describedBy, $ariaDescribedBy, $ariaInvalid, $errorFieldName);