mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-08-10 07:11:18 +02:00
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:
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 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 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user