Files
xamxam/src/App.php
Pontoporeia c2eff75789 WCAG 3.3.1: autofocus first invalid field on add/edit form validation failure
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.
2026-04-06 15:33:08 +02:00

190 lines
6.5 KiB
PHP

<?php
/**
* Thin application helper — centralises bootstrap, auth gating, CSRF lifecycle,
* flash messages, redirect, and template rendering.
*
* Eliminates the ~6-8 lines of identical preamble repeated across every page
* and action handler. See REFACTORING_RECOMMENDATIONS.md §1 + §3.
*/
class App
{
private static bool $booted = false;
// ── Bootstrap ─────────────────────────────────────────────────────────────
/**
* Boot once per request: load Database, ensure CSRF token exists.
* Suitable for public pages (no auth required).
*/
public static function boot(): Database
{
if (!self::$booted) {
require_once APP_ROOT . '/src/Database.php';
self::$booted = true;
}
self::ensureCsrf();
return Database::getInstance();
}
/**
* Gate for admin pages: require auth + CSRF token + load Database.
*/
public static function adminGuard(): Database
{
require_once APP_ROOT . '/src/AdminAuth.php';
AdminAuth::requireLogin();
return self::boot();
}
// ── CSRF lifecycle ────────────────────────────────────────────────────────
/**
* Ensure a CSRF token exists in the session.
* Called automatically by boot() / adminGuard().
*/
private static function ensureCsrf(): void
{
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
}
/**
* Validate the CSRF token on a POST request.
* Halts with 403 if the token is missing or invalid.
*/
public static function verifyCsrf(): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST'
|| !isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
http_response_code(403);
exit('CSRF token invalide.');
}
}
/**
* Regenerate the CSRF token after a successful mutation.
*/
public static function rotateCsrf(): void
{
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// ── Flash messages ────────────────────────────────────────────────────────
/**
* Store a flash message in the session.
*
* @param 'success'|'error' $type
*/
public static function flash(string $type, string $message): void
{
$_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.
*
* Also drains the legacy per-page keys (error, success, admin_error,
* admin_success, edit_error, edit_success, form_error) so the transition
* to unified keys can happen incrementally.
*
* @return array{error: ?string, success: ?string}
*/
public static function consumeFlash(): array
{
// Unified keys first, fall back to any legacy key that is set.
$error = $_SESSION['_flash_error']
?? $_SESSION['error']
?? $_SESSION['admin_error']
?? $_SESSION['edit_error']
?? $_SESSION['form_error']
?? null;
$success = $_SESSION['_flash_success']
?? $_SESSION['success']
?? $_SESSION['admin_success']
?? $_SESSION['edit_success']
?? null;
// Clear all variants.
unset(
$_SESSION['_flash_error'], $_SESSION['_flash_success'],
$_SESSION['error'], $_SESSION['success'],
$_SESSION['admin_error'], $_SESSION['admin_success'],
$_SESSION['edit_error'], $_SESSION['edit_success'],
$_SESSION['form_error']
);
// Note: autofocus is consumed separately via consumeAutofocus().
return ['error' => $error, 'success' => $success];
}
// ── Redirect ──────────────────────────────────────────────────────────────
/**
* Flash a message and redirect. Terminates the script.
*/
public static function redirect(string $url, ?string $success = null, ?string $error = null): never
{
if ($success !== null) {
self::flash('success', $success);
}
if ($error !== null) {
self::flash('error', $error);
}
header('Location: ' . $url);
exit;
}
// ── Template rendering ────────────────────────────────────────────────────
/**
* Render a full page: head → header → content template → footer.
*
* Expects $vars to contain the same keys the templates already rely on
* ($pageTitle, $bodyClass, $isAdmin, $extraCss, $ogTags, etc.).
* The footer variant (public vs admin) is chosen automatically based
* on $isAdmin.
*
* @param string $template Path relative to APP_ROOT/templates/
* @param array $vars Variables to expose inside the templates
*/
public static function render(string $template, array $vars = []): void
{
// Make all vars available in the template scope.
extract($vars);
include APP_ROOT . '/templates/head.php';
include APP_ROOT . '/templates/header.php';
include APP_ROOT . '/templates/' . $template;
if (!empty($isAdmin)) {
include APP_ROOT . '/templates/admin/footer.php';
} else {
include APP_ROOT . '/templates/footer.php';
}
}
}