repertoire: HTMX OOB swaps for filter columns + fix fading edge cases

Switch from swapping the entire #repertoire-index block to targeted
hx-swap-oob swaps: only the <ul> list elements are replaced, while
section headers and accordion chrome stay in the DOM untouched.

- Filter column <ul>s get IDs (rep-list-years, rep-list-ap, etc.)
  and render with hx-swap-oob=true on HTMX requests
- Students <ul> gets id=rep-students as main swap target
- Filter buttons now target #rep-students instead of #repertoire-index
- Server sets $isOob=true when rendering HTMX partial responses
- Accordion re-init on swap no longer needed (headers never replaced)
- Scroll-restore updated to capture/restore per <ul> ID

Fading fixes:
- Restore rep-entry--faded class with simplified logic: any active
  filter fades entries whose matched flag is false (removed the
  $colHasMatch gate that prevented fading when a column had no matches)
- Add $noResults guard: when matched_ids is empty (zero theses match
  all active filters), force-fade all non-selected entries across ALL
  columns. This covers the keyword edge case where per-column matched
  is computed excluding the keyword filter (many-to-many), causing
  keywords to appear valid even though the full intersection is empty.
This commit is contained in:
Pontoporeia
2026-07-10 12:59:12 +02:00
parent f86deaf02f
commit ec9c140ba2
4 changed files with 113 additions and 68 deletions
+7
View File
@@ -359,6 +359,13 @@
color: var(--accent-primary); color: var(--accent-primary);
} }
/* Faded/disabled: no valid results for this filter combination */
.rep-entry--faded {
opacity: 0.3;
cursor: not-allowed;
pointer-events: none;
}
/* Years column — big numbers, semi-bold (BBBDMSans Medium weight) */ /* Years column — big numbers, semi-bold (BBBDMSans Medium weight) */
.repertoire-col[data-col="years"] .rep-entry { .repertoire-col[data-col="years"] .rep-entry {
font-size: var(--step-3); font-size: var(--step-3);
@@ -1,52 +1,39 @@
/** /**
* repertoire-scroll-restore.js — Preserve column scroll positions on HTMX swap. * repertoire-scroll-restore.js — Preserve column scroll positions on HTMX OOB swap.
* *
* When a filter button triggers an HTMX swap that replaces #repertoire-index, * Filter clicks trigger HTMX requests that swap individual <ul> elements via
* the new markup replaces the old, resetting all column scroll positions to 0. * hx-swap-oob. This module captures scrollTop of all rep-list-* <ul>s and the
* This module captures scrollTop of each scrollable <ul> before the swap and * students <ul> before the request and restores them after the response settles.
* restores it after, keyed by data-col attribute so the mapping survives
* DOM replacement.
*/ */
(() => { (() => {
var INDEX_SEL = "#repertoire-index"; var LIST_SEL = "ul[id^='rep-list-'], ul#rep-students";
var scrollSnapshots = {}; var scrollSnapshots = {};
function snapshot() { function snapshot() {
var index = document.querySelector(INDEX_SEL);
if (!index) return;
scrollSnapshots = {}; scrollSnapshots = {};
index.querySelectorAll(".repertoire-col[data-col] > ul").forEach((ul) => { document.querySelectorAll(LIST_SEL).forEach((ul) => {
var col = ul.closest(".repertoire-col"); scrollSnapshots[ul.id] = ul.scrollTop;
if (!col) return;
var key = col.getAttribute("data-col");
scrollSnapshots[key] = ul.scrollTop;
}); });
} }
function restore() { function restore() {
var index = document.querySelector(INDEX_SEL); document.querySelectorAll(LIST_SEL).forEach((ul) => {
if (!index) return; if (scrollSnapshots[ul.id] !== undefined) {
index.querySelectorAll(".repertoire-col[data-col] > ul").forEach((ul) => { ul.scrollTop = scrollSnapshots[ul.id];
var col = ul.closest(".repertoire-col");
if (!col) return;
var key = col.getAttribute("data-col");
if (scrollSnapshots[key] !== undefined) {
ul.scrollTop = scrollSnapshots[key];
} }
}); });
} }
// Capture scroll positions before the outgoing element is replaced // Capture before any HTMX request targeting rep-students
document.body.addEventListener("htmx:beforeSwap", (e) => { document.body.addEventListener("htmx:beforeRequest", (e) => {
if (e.detail.target?.matches?.(INDEX_SEL)) { if (e.detail.target?.id === "rep-students") {
snapshot(); snapshot();
} }
}); });
// Restore after the new content is in the DOM // Restore after the response has settled into the DOM
document.body.addEventListener("htmx:afterSwap", (e) => { document.body.addEventListener("htmx:afterSettle", (e) => {
if (e.detail.target?.matches?.(INDEX_SEL)) { if (e.detail.target?.id === "rep-students") {
// Use requestAnimationFrame to ensure layout has settled
requestAnimationFrame(() => { requestAnimationFrame(() => {
restore(); restore();
}); });
+1
View File
@@ -277,6 +277,7 @@ class SearchController
array $repData, array $repData,
array $activeFilters, array $activeFilters,
): never { ): never {
$isOob = true;
header('Content-Type: text/html; charset=UTF-8'); header('Content-Type: text/html; charset=UTF-8');
include APP_ROOT . '/templates/partials/repertoire-index.php'; include APP_ROOT . '/templates/partials/repertoire-index.php';
exit(); exit();
+87 -37
View File
@@ -3,11 +3,17 @@
* Partial: répertoire index columns. * Partial: répertoire index columns.
* Rendered both on full page load and as HTMX partial swap. * Rendered both on full page load and as HTMX partial swap.
* *
* When $isOob is true, renders only <ul> elements with hx-swap-oob attributes
* for the filter columns, and the students <ul> as the main swap target.
*
* Expected variables: * Expected variables:
* $repData array output of Database::getRepertoireFilterData() * $repData array output of Database::getRepertoireFilterData()
* $activeFilters array{years:int[], ap:string[], or:string[], fi:string[], kw:string[]} * $activeFilters array{years:int[], ap:string[], or:string[], fi:string[], kw:string[]}
* $isOob bool (optional) render OOB-only response for HTMX swaps
*/ */
$isOob = $isOob ?? false;
$activeSets = [ $activeSets = [
'years' => array_map('strval', $activeFilters['years'] ?? []), 'years' => array_map('strval', $activeFilters['years'] ?? []),
'ap' => $activeFilters['ap'] ?? [], 'ap' => $activeFilters['ap'] ?? [],
@@ -58,60 +64,45 @@ function repToggleUrl(array $sets, string $dim, string $value): string {
return '/repertoire' . ($qs ? '?' . $qs : ''); return '/repertoire' . ($qs ? '?' . $qs : '');
} }
/**
* Render a single filter entry <li>.
*
* Fading logic: when any filter is active, entries that would yield zero
* results (matched=false) are faded and disabled. The selected entry itself
* is never faded.
*/
function repFilterEntry( function repFilterEntry(
array $item, array $item,
string $dim, string $dim,
array $activeSets, array $activeSets,
bool $anyActive,
bool $noResults,
string $hx, string $hx,
): void { ): void {
$val = (string)$item['value']; $val = (string)$item['value'];
$isActive = in_array($val, $activeSets[$dim], true); $isActive = in_array($val, $activeSets[$dim], true);
$isFaded = $anyActive && ($noResults || !$item['matched']) && !$isActive;
$cls = 'rep-entry' $cls = 'rep-entry'
. ($isActive ? ' rep-entry--selected' : ''); . ($isActive ? ' rep-entry--selected' : '')
. ($isFaded ? ' rep-entry--faded' : '');
$url = repToggleUrl($activeSets, $dim, $val); $url = repToggleUrl($activeSets, $dim, $val);
?> ?>
<li> <li>
<button type="button" class="<?= $cls ?>" <button type="button" class="<?= $cls ?>"
aria-pressed="<?= $isActive ? 'true' : 'false' ?>" aria-pressed="<?= $isActive ? 'true' : 'false' ?>"
hx-get="<?= htmlspecialchars($url) ?>" <?= $hx ?>> <?= $isFaded ? 'disabled' : "hx-get=\"" . htmlspecialchars($url) . "\" $hx" ?>>
<?= htmlspecialchars($dim === 'ap' ? formatApDisplay($val) : $val) ?> <?= htmlspecialchars($dim === 'ap' ? formatApDisplay($val) : $val) ?>
</button> </button>
</li> </li>
<?php <?php
} }
// ── Column definitions ────────────────────────────────────────────────────── /**
$hx = 'hx-target="#repertoire-index" hx-swap="outerHTML" hx-push-url="true" hx-indicator="#rep-indicator"'; * Render the students <ul> (no section chrome).
*/
$filterColumns = [ function renderStudentsList(array $studentWorks): void {
['dataKey' => 'years', 'dim' => 'years', 'heading' => 'Années'],
['dataKey' => 'ap_programs', 'dim' => 'ap', 'heading' => 'Ateliers Pluridisciplinaires'],
['dataKey' => 'orientations', 'dim' => 'or', 'heading' => 'Orientations'],
['dataKey' => 'finality_types', 'dim' => 'fi', 'heading' => 'Finalité du&nbsp;Master'],
['dataKey' => 'keywords', 'dim' => 'kw', 'heading' => 'Mots-clés'],
];
?> ?>
<div id="repertoire-index" class="repertoire-index"> <ul id="rep-students">
<?php
// Render filter columns in the correct left-to-right order.
// Students column (non-filter) is inserted between keywords and AP/or/fi/years.
$renderOrder = ['years', 'ap', 'or', 'fi', 'students', 'kw'];
foreach ($renderOrder as $colKey):
if ($colKey === 'students'): ?>
<!-- ÉTUDIANTES -->
<section class="repertoire-col rep-accordion" data-col="students">
<h2>
<span class="rep-accordion__heading-text">Étudiant·es</span>
<button type="button" class="rep-accordion__toggle" aria-expanded="false">
Étudiant·es
<span class="rep-accordion__chevron" aria-hidden="true"></span>
</button>
</h2>
<div class="rep-accordion__panel">
<ul>
<?php if (empty($studentWorks)): ?> <?php if (empty($studentWorks)): ?>
<li class="rep-empty">—</li> <li class="rep-empty">—</li>
<?php else: ?> <?php else: ?>
@@ -135,13 +126,72 @@ foreach ($renderOrder as $colKey):
<?php endforeach; ?> <?php endforeach; ?>
<?php endif; ?> <?php endif; ?>
</ul> </ul>
<?php
}
// ── Column definitions ──────────────────────────────────────────────────────
$hx = 'hx-target="#rep-students" hx-swap="outerHTML" hx-push-url="true" hx-indicator="#rep-indicator"';
$anyActive = !empty($activeSets['years']) || !empty($activeSets['ap'])
|| !empty($activeSets['or']) || !empty($activeSets['fi'])
|| !empty($activeSets['kw']);
$noResults = $anyActive && empty($repData['matched_ids']);
$filterColumns = [
['dataKey' => 'years', 'dim' => 'years', 'heading' => 'Années'],
['dataKey' => 'ap_programs', 'dim' => 'ap', 'heading' => 'Ateliers Pluridisciplinaires'],
['dataKey' => 'orientations', 'dim' => 'or', 'heading' => 'Orientations'],
['dataKey' => 'finality_types', 'dim' => 'fi', 'heading' => 'Finalité du&nbsp;Master'],
['dataKey' => 'keywords', 'dim' => 'kw', 'heading' => 'Mots-clés'],
];
// Render order: students column is inserted between fi and kw
$renderOrder = ['years', 'ap', 'or', 'fi', 'students', 'kw'];
// ── OOB response: only <ul> elements, no section chrome ─────────────────────
if ($isOob):
foreach ($renderOrder as $colKey):
if ($colKey === 'students'):
renderStudentsList($studentWorks);
else:
$col = array_values(array_filter($filterColumns, fn($c) => $c['dim'] === $colKey))[0];
$listId = 'rep-list-' . $colKey;
?>
<ul id="<?= $listId ?>" hx-swap-oob="true">
<?php foreach ($repData[$col['dataKey']] as $item):
repFilterEntry($item, $col['dim'], $activeSets, $anyActive, $noResults, $hx);
endforeach; ?>
</ul>
<?php endif;
endforeach;
return; // renderRepertoirePartial will exit() after include
endif;
// ── Full page load ──────────────────────────────────────────────────────────
?>
<div id="repertoire-index" class="repertoire-index">
<?php
foreach ($renderOrder as $colKey):
if ($colKey === 'students'): ?>
<!-- ÉTUDIANTES -->
<section class="repertoire-col rep-accordion" data-col="students">
<h2>
<span class="rep-accordion__heading-text">Étudiant·es</span>
<button type="button" class="rep-accordion__toggle" aria-expanded="false">
Étudiant·es
<span class="rep-accordion__chevron" aria-hidden="true"></span>
</button>
</h2>
<div class="rep-accordion__panel">
<?php renderStudentsList($studentWorks); ?>
</div> </div>
</section> </section>
<?php else: <?php else:
$col = array_values(array_filter($filterColumns, fn($c) => $c['dim'] === $colKey))[0]; $col = array_values(array_filter($filterColumns, fn($c) => $c['dim'] === $colKey))[0];
// Count active filters in this column for the badge
$activeCount = count($activeSets[$col['dim']]); $activeCount = count($activeSets[$col['dim']]);
$listId = 'rep-list-' . $colKey;
?> ?>
<section class="repertoire-col rep-accordion" data-col="<?= $col['dim'] ?>"> <section class="repertoire-col rep-accordion" data-col="<?= $col['dim'] ?>">
<h2> <h2>
@@ -155,9 +205,9 @@ foreach ($renderOrder as $colKey):
</button> </button>
</h2> </h2>
<div class="rep-accordion__panel"> <div class="rep-accordion__panel">
<ul> <ul id="<?= $listId ?>">
<?php foreach ($repData[$col['dataKey']] as $item): <?php foreach ($repData[$col['dataKey']] as $item):
repFilterEntry($item, $col['dim'], $activeSets, $hx); repFilterEntry($item, $col['dim'], $activeSets, $anyActive, $noResults, $hx);
endforeach; ?> endforeach; ?>
</ul> </ul>
</div> </div>