Semantic HTML: home page card grid — <ul>/<li>/<figure>/<nav> refactor

Replace presentational divs in index.php and main.css with elements that
carry correct semantic meaning, fixing multiple WCAG 2.1 AA issues:

index.php:
- <div class="cards-container"> → <ul class="cards-container"> (list of navigable items)
- <a class="card-link"><div class="card">…</div></a> → <li class="card"><a> (block link
  is the <a>, <li> is the container; removes the redundant .card div wrapper)
- <div class="card__media"> → <figure class="card__media"> when wrapping an <img>;
  gradient placeholder stays as <div> (presentational, aria-hidden)
- Improved alt text: "Couverture — [title] par [authors]" instead of bare title
- Removed <div class="card__info"> wrapper; caption is now a bare <p class="card__caption">
  directly inside the <a>
- <div class="filter-info"> → <p class="filter-info" role="status"> (live-region
  semantics; announces filter state to screen readers)
- ✕ symbol in clear-filter link wrapped in <span aria-hidden="true">
- Gradient placeholder div gets aria-hidden="true" (decorative; caption below carries text)
- Empty-state <p style="…"> → <li class="cards-empty"> (removes inline style)
- <div class="pagination-wrap"> → <nav class="pagination-wrap" aria-label="Pagination">
  with <ul>/<li> children; page-info <span> → <li aria-current="page">

main.css:
- .cards-container: add list-style:none; margin:0; padding:0 (reset <ul> defaults)
- Remove .card-link rule; replace with .card > a (block flex link, no separate class)
- .card__media: add margin:0 to reset <figure> default margin
- Remove .card__info rules; rename .authors to .card__caption with same styles
- Add .cards-empty rule (removes last inline style from index.php)
- .pagination-wrap: restructured for <nav>/<ul>; inner <ul> carries the flex layout
- prefers-reduced-motion: add .card__media--gradient guard

WCAG criteria addressed: 1.1.1 (alt text), 1.3.1 (info & relationships via semantic
list/figure), 2.4.1 (filter-info now live region), role="status" on filter banner.
This commit is contained in:
Pontoporeia
2026-03-29 16:13:02 +02:00
parent c352a392a1
commit ac872c1fe0
3 changed files with 114 additions and 94 deletions

16
TODO.md
View File

@@ -498,7 +498,7 @@ Goal: rename the tables and column to the canonical M2M pattern (`tags`, `thesis
`.tfe-back-link`, `.tfe-restricted` in `tfe.css` `.tfe-back-link`, `.tfe-restricted` in `tfe.css`
- `admin/edit.php`: multiple `style=` on `.admin-form-row` and banner preview → modifier - `admin/edit.php`: multiple `style=` on `.admin-form-row` and banner preview → modifier
classes in `admin.css` classes in `admin.css`
- `index.php` line 146: `style="padding:2rem;color:#666;"``.cards-empty` in `main.css` - [x] `index.php` line 146: `style="padding:2rem;color:#666;"``.cards-empty` in `main.css`
- [x] **`.site-nav__right` is a duplicate of `.site-nav__link`** - removed `.site-nav__right` block - [x] **`.site-nav__right` is a duplicate of `.site-nav__link`** - removed `.site-nav__right` block
from `common.css`; updated `nav.php` to use `.site-nav__link` on the À Propos link. from `common.css`; updated `nav.php` to use `.site-nav__link` on the À Propos link.
@@ -580,27 +580,27 @@ The design does **not** need to change - only the vocabulary of the markup.
### II - `public/index.php` ### II - `public/index.php`
- [ ] **`<div class="filter-info">`** is a status/notice banner. Use `<p role="status">` or - [x] **`<div class="filter-info">`** is a status/notice banner. Use `<p role="status">` or
`<output>` - both carry live-region semantics for screen readers without extra ARIA. `<output>` - both carry live-region semantics for screen readers without extra ARIA.
- [ ] **`<div class="cards-container">`** is a list of navigable items. Replace with `<ul>` - - [x] **`<div class="cards-container">`** is a list of navigable items. Replace with `<ul>` -
removing the wrapper div and making each card an `<li>`. `.cards-container` → target `main > ul` removing the wrapper div and making each card an `<li>`. `.cards-container` → target `main > ul`
or a single class on `<ul>`. or a single class on `<ul>`.
- [ ] **`<a class="card-link"><div class="card">...</div></a>`** - the outer `<a>` wrapping a `<div>` - [x] **`<a class="card-link"><div class="card">...</div></a>`** - the outer `<a>` wrapping a `<div>`
makes the div redundant. The `<a>` is already a block element (set `display:block`). The makes the div redundant. The `<a>` is already a block element (set `display:block`). The
`.card` div can be removed; CSS targets `ul li a` directly. The `<li>` inside the `<ul>` `.card` div can be removed; CSS targets `ul li a` directly. The `<li>` inside the `<ul>`
becomes the card container. becomes the card container.
- [ ] **`<div class="card__media">`** - this is the image/media wrapper inside each card. - [x] **`<div class="card__media">`** - this is the image/media wrapper inside each card.
When it contains an `<img>`, use `<figure>` (a self-contained media unit). When it shows When it contains an `<img>`, use `<figure>` (a self-contained media unit). When it shows
the gradient placeholder (no real image), a plain `<div>` is fine since it's presentational. the gradient placeholder (no real image), a plain `<div>` is fine since it's presentational.
- [ ] **`<div class="card__info"><p class="authors">...</p></div>`** - the `.card__info` wrapper - [x] **`<div class="card__info"><p class="authors">...</p></div>`** - the `.card__info` wrapper
exists only to add padding. Move the padding to the `<p>` or `<li>` directly; remove the exists only to add padding. Move the padding to the `<p>` or `<li>` directly; remove the
div. The `<p>` stays. `.authors` class → either keep it or target `li > p`. div. The `<p>` stays. `.authors` class → either keep it or target `li > p`.
- [ ] **`<div class="pagination-wrap">`** with `<a class="pagination-btn">` and - [x] **`<div class="pagination-wrap">`** with `<a class="pagination-btn">` and
`<span class="pagination-info">` - replace with `<nav aria-label="Pagination"><ul>...</ul></nav>`. `<span class="pagination-info">` - replace with `<nav aria-label="Pagination"><ul>...</ul></nav>`.
Each button becomes an `<li>`. The disabled state uses `aria-disabled="true"` + Each button becomes an `<li>`. The disabled state uses `aria-disabled="true"` +
`tabindex="-1"` instead of a `.disabled` class alone (which has no keyboard semantics). `tabindex="-1"` instead of a `.disabled` class alone (which has no keyboard semantics).
@@ -927,7 +927,7 @@ Current state: **zero ARIA attributes, zero skip links, zero focus-visible style
#### 1.1.1 Non-text content (alt text) #### 1.1.1 Non-text content (alt text)
- [ ] **Home card images use the thesis title as `alt`** - `alt="<?= $item['title'] ?>"` is a - [x] **Home card images use the thesis title as `alt`** - `alt="<?= $item['title'] ?>"` is a
reasonable fallback, but the title alone provides no context about what the image depicts. reasonable fallback, but the title alone provides no context about what the image depicts.
Prefer `"Couverture - [titre] par [auteurs]"` for cover images, or `""` (empty) for purely Prefer `"Couverture - [titre] par [auteurs]"` for cover images, or `""` (empty) for purely
decorative banners where the caption below already carries all the text information. decorative banners where the caption below already carries all the text information.

View File

@@ -22,6 +22,9 @@
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 0; gap: 0;
list-style: none;
margin: 0;
padding: 0;
} }
@media (min-width: 1400px) { @media (min-width: 1400px) {
@@ -36,13 +39,7 @@
} }
} }
/* Each card = media thumbnail + text below */ /* Each card = list item containing a block <a> link */
.card-link {
text-decoration: none;
color: inherit;
display: block;
}
.card { .card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -52,12 +49,22 @@
overflow: hidden; overflow: hidden;
} }
.card > a {
text-decoration: none;
color: inherit;
display: flex;
flex-direction: column;
flex: 1;
}
/* card__media is a <figure> (with image) or <div> (gradient placeholder) */
.card__media { .card__media {
width: 100%; width: 100%;
aspect-ratio: 4/3; aspect-ratio: 4/3;
overflow: hidden; overflow: hidden;
background: #e8e8e8; background: #e8e8e8;
position: relative; position: relative;
margin: 0; /* reset <figure> default margin */
} }
.card__media img, .card__media img,
@@ -120,26 +127,22 @@
font-style: italic; font-style: italic;
} }
.card__info { /* Card caption — <p> directly inside the <a>, below the media */
.card__caption {
padding: 0.55rem 0.5rem 0.65rem; padding: 0.55rem 0.5rem 0.65rem;
font-size: 0.88rem; font-size: 0.88rem;
line-height: 1.35; line-height: 1.35;
color: var(--black); color: var(--black);
}
.card__info .authors {
margin: 0; margin: 0;
font-weight: 400; font-weight: 400;
} }
.card__info .title { /* Empty-state message shown when no cards exist */
margin: 0; .cards-empty {
font-weight: 400; padding: 2rem;
color: var(--text-muted); color: #6b6b6b;
display: -webkit-box; font-size: 0.9rem;
-webkit-line-clamp: 2; list-style: none;
-webkit-box-orient: vertical;
overflow: hidden;
} }
/* Filter info */ /* Filter info */
@@ -169,14 +172,19 @@
/* Pagination */ /* Pagination */
.pagination-wrap { .pagination-wrap {
border-top: 1px solid var(--border-color);
background: var(--white);
flex-shrink: 0;
}
.pagination-wrap ul {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
padding: 1rem; padding: 1rem;
border-top: 1px solid var(--border-color); list-style: none;
background: var(--white); margin: 0;
flex-shrink: 0;
} }
.pagination-btn { .pagination-btn {
@@ -226,4 +234,7 @@
.card:hover .card__media video { .card:hover .card__media video {
transform: none; transform: none;
} }
.card__media--gradient {
transition: none;
}
} }

View File

@@ -69,89 +69,98 @@ $extraCss = ['assets/main.css'];
<?php include APP_ROOT . '/templates/search-bar.php'; ?> <?php include APP_ROOT . '/templates/search-bar.php'; ?>
<?php if ($year): ?> <?php if ($year): ?>
<div class="filter-info"> <p class="filter-info" role="status">
Année : <?= htmlspecialchars($year) ?> Année : <?= htmlspecialchars($year) ?>
<a href="index.php" class="clear-filter"> Réinitialiser</a> <a href="index.php" class="clear-filter"><span aria-hidden="true">✕</span> Réinitialiser</a>
</div> </p>
<?php elseif ($isDefaultView && !empty($latestYear)): ?> <?php elseif ($isDefaultView && !empty($latestYear)): ?>
<div class="filter-info home-latest-label"> <p class="filter-info home-latest-label" role="status">
Découvrez les TFE de <?= (int)$latestYear ?> — sélection aléatoire Découvrez les TFE de <?= (int)$latestYear ?> — sélection aléatoire
</div> </p>
<?php endif; ?> <?php endif; ?>
<main class="home-main" id="main-content"> <main class="home-main" id="main-content">
<div class="cards-container"> <ul class="cards-container">
<?php foreach ($itemsToLoad as $item): ?> <?php foreach ($itemsToLoad as $item): ?>
<a href="tfe.php?id=<?= (int)$item["id"] ?>" class="card-link"> <li class="card">
<div class="card"> <a href="tfe.php?id=<?= (int)$item["id"] ?>">
<div class="card__media"> <?php
<?php // Resolve thumbnail: banner_path → cover file → gradient placeholder
// Resolve thumbnail: banner_path → cover file → gradient placeholder $thumb = null;
$thumb = null;
// 1. Banner path (dedicated home thumbnail) // 1. Banner path (dedicated home thumbnail)
if (!empty($item['banner_path'])) { if (!empty($item['banner_path'])) {
$thumb = $item['banner_path']; $thumb = $item['banner_path'];
} }
// 2. Cover image from thesis_files (batch-loaded above) // 2. Cover image from thesis_files (batch-loaded above)
if (!$thumb && isset($coverMap[$item['id']])) { if (!$thumb && isset($coverMap[$item['id']])) {
$thumb = $coverMap[$item['id']]; $thumb = $coverMap[$item['id']];
} }
// 3. Fall through to gradient // 3. Fall through to gradient
?> ?>
<?php if ($thumb): ?> <?php if ($thumb): ?>
<figure class="card__media">
<img src="/media.php?path=<?= urlencode($thumb) ?>" <img src="/media.php?path=<?= urlencode($thumb) ?>"
alt="<?= htmlspecialchars($item['title']) ?>" alt="Couverture — <?= htmlspecialchars($item['title']) ?> par <?= htmlspecialchars($item['authors'] ?? '') ?>"
loading="lazy"> loading="lazy">
<?php else: ?> </figure>
<?php <?php else: ?>
$hue = ($item['id'] * 47 + 160) % 360; <?php
$hue2 = ($hue + 40) % 360; $hue = ($item['id'] * 47 + 160) % 360;
?> $hue2 = ($hue + 40) % 360;
<div class="card__media--gradient" ?>
style="background:linear-gradient(135deg,hsl(<?= $hue ?>,55%,40%),hsl(<?= $hue2 ?>,50%,28%));"> <div class="card__media card__media--gradient"
<span class="card__gradient-author"><?= htmlspecialchars($item['authors'] ?? '') ?></span> style="background:linear-gradient(135deg,hsl(<?= $hue ?>,55%,40%),hsl(<?= $hue2 ?>,50%,28%));"
<span class="card__gradient-title"><?= htmlspecialchars($item['title']) ?></span> aria-hidden="true">
</div> <span class="card__gradient-author"><?= htmlspecialchars($item['authors'] ?? '') ?></span>
<?php endif; ?> <span class="card__gradient-title"><?= htmlspecialchars($item['title']) ?></span>
</div> </div>
<div class="card__info"> <?php endif; ?>
<p class="authors"><?= htmlspecialchars($item["authors"] ?? '') ?><?php if (!empty($item['authors']) && !empty($item['title'])): ?> <?php endif; ?><?= htmlspecialchars($item["title"]) ?></p> <p class="card__caption"><?= htmlspecialchars($item["authors"] ?? '') ?><?php if (!empty($item['authors']) && !empty($item['title'])): ?> <?php endif; ?><?= htmlspecialchars($item["title"]) ?></p>
</div> </a>
</div> </li>
</a>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($itemsToLoad)): ?> <?php if (empty($itemsToLoad)): ?>
<p style="padding:2rem;color:#666;">Aucun mémoire trouvé.</p> <li class="cards-empty">Aucun mémoire trouvé.</li>
<?php endif; ?> <?php endif; ?>
</div> </ul>
<?php if ($totalPages > 1): ?> <?php if ($totalPages > 1): ?>
<div class="pagination-wrap"> <nav class="pagination-wrap" aria-label="Pagination">
<?php $yearParam = $year ? '&year=' . (int)$year : ''; ?> <?php $yearParam = $year ? '&year=' . (int)$year : ''; ?>
<a href="?page=1<?= $yearParam ?>" <ul>
class="pagination-btn <?= $page <= 1 ? 'disabled' : '' ?>" <li>
<?= $page <= 1 ? 'aria-disabled="true" tabindex="-1"' : '' ?> <a href="?page=1<?= $yearParam ?>"
aria-label="Première page">«</a> class="pagination-btn <?= $page <= 1 ? 'disabled' : '' ?>"
<a href="?page=<?= max(1, $page - 1) . $yearParam ?>" <?= $page <= 1 ? 'aria-disabled="true" tabindex="-1"' : '' ?>
class="pagination-btn <?= $page <= 1 ? 'disabled' : '' ?>" aria-label="Première page">«</a>
<?= $page <= 1 ? 'aria-disabled="true" tabindex="-1"' : '' ?> </li>
aria-label="Page précédente"></a> <li>
<span class="pagination-info"> <a href="?page=<?= max(1, $page - 1) . $yearParam ?>"
<span class="page-current"><?= $page ?></span> / <?= $totalPages ?> class="pagination-btn <?= $page <= 1 ? 'disabled' : '' ?>"
</span> <?= $page <= 1 ? 'aria-disabled="true" tabindex="-1"' : '' ?>
<a href="?page=<?= min($totalPages, $page + 1) . $yearParam ?>" aria-label="Page précédente"></a>
class="pagination-btn <?= $page >= $totalPages ? 'disabled' : '' ?>" </li>
<?= $page >= $totalPages ? 'aria-disabled="true" tabindex="-1"' : '' ?> <li class="pagination-info" aria-current="page">
aria-label="Page suivante"></a> <span class="page-current"><?= $page ?></span> / <?= $totalPages ?>
<a href="?page=<?= $totalPages . $yearParam ?>" </li>
class="pagination-btn <?= $page >= $totalPages ? 'disabled' : '' ?>" <li>
<?= $page >= $totalPages ? 'aria-disabled="true" tabindex="-1"' : '' ?> <a href="?page=<?= min($totalPages, $page + 1) . $yearParam ?>"
aria-label="Dernière page">»</a> class="pagination-btn <?= $page >= $totalPages ? 'disabled' : '' ?>"
</div> <?= $page >= $totalPages ? 'aria-disabled="true" tabindex="-1"' : '' ?>
aria-label="Page suivante"></a>
</li>
<li>
<a href="?page=<?= $totalPages . $yearParam ?>"
class="pagination-btn <?= $page >= $totalPages ? 'disabled' : '' ?>"
<?= $page >= $totalPages ? 'aria-disabled="true" tabindex="-1"' : '' ?>
aria-label="Dernière page">»</a>
</li>
</ul>
</nav>
<?php endif; ?> <?php endif; ?>
</main> </main>