Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
15 KiB
Défilement horizontal ergonomique des tableaux (#82) — Plan d'implémentation
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Rendre le défilement horizontal des tableaux toujours accessible (barre miroir en haut, synchronisée) et garder les en-têtes de colonnes visibles pendant le défilement vertical de la page.
Architecture: Option B (sticky au niveau page). Le tableau reste dans le flux de la page. CSS rend les en-têtes position: sticky; top: 0. Une barre de défilement « miroir » est injectée par JS au-dessus de chaque tableau, sticky en haut, et synchronisée avec le conteneur scrollable du tableau. Aucun scroll vertical imbriqué.
Tech Stack: Dash 3.4 dash_table.DataTable, CSS (src/assets/css/style.css), JS vanilla auto-chargé depuis src/assets/ (pattern existant : MutationObserver, cf. dash_clientside.js), tests Selenium (dash[testing] / DashComposite).
Global Constraints
- Importer les modules de l'app avec le préfixe
src.(ex.src.figures), jamaisfigures. - UI en français.
- Cibler la classe partagée
marches_table(présente sur les 4 pages :/tableau,/acheteur,/titulaire,/observatoire) — pas de duplication par page. - Ne pas modifier la hauteur du tableau ni introduire de conteneur à hauteur fixe (option A explicitement écartée).
- Les commits référencent
#82. - Le pre-commit hook lance
prettier(CSS/JS/MD) etruff(Python) et modifie les fichiers : après un échec dû au reformatage, refairegit addpuis recommiter. - Terminer chaque message de commit par :
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>.
File Structure
| Fichier | Responsabilité | Action |
|---|---|---|
src/assets/css/style.css |
En-têtes sticky + neutralisation overflow interne Dash + styles de la barre miroir | Modifier |
src/assets/table_hscroll.js |
Injecter la barre miroir au-dessus de chaque .marches_table, synchroniser le scroll, masquer si pas de débordement, recalculer au resize / re-render |
Créer |
tests/test_tableau_hscroll.py |
Test Selenium : barre miroir présente + en-tête sticky sur /tableau |
Créer |
docs/superpowers/specs/2026-06-23-tableaux-scroll-horizontal-sticky-design.md |
Consigner le verdict du spike (Task 1) | Modifier |
Décision DOM clé : la barre miroir est injectée par JS (et non ajoutée dans le markup Python), ce qui évite de toucher au markup des 4 pages et fonctionne quel que soit le wrapper. Le conteneur scrollable du tableau est l'élément Dash .dash-spreadsheet-container (ou son parent direct), confirmé au spike.
Task 1 : Spike — vérifier le DOM réel et la technique sticky
But : lever l'inconnue principale (structure DOM de DataTable + compatibilité position: sticky des en-têtes avec un conteneur overflow-x). Produit un verdict écrit qui pilote les tâches suivantes.
Files:
-
Modify:
docs/superpowers/specs/2026-06-23-tableaux-scroll-horizontal-sticky-design.md(section « Verdict du spike ») -
Step 1 : Lancer l'app
source .venv/bin/activate
python run.py
Ouvrir http://127.0.0.1:8050/tableau dans le navigateur.
- Step 2 : Inspecter le DOM du tableau
Dans les DevTools, sur le tableau, relever et noter :
-
l'élément qui porte la largeur totale du tableau (table
.cell-table) et sa largeur (scrollWidth) vs. celle de son parent (clientWidth) → confirme le débordement ; -
l'élément qui (le cas échéant) porte déjà un
overflow/overflow-x(regarder.dash-spreadsheet-container,.dash-spreadsheet-inner) via l'onglet Computed ; -
le sélecteur exact de la ligne d'en-tête (attendu :
th.dash-headerdans untr). -
Step 3 : Tester l'hypothèse sticky en live
Dans la console DevTools, appliquer à chaud :
document
.querySelectorAll(
".marches_table .dash-spreadsheet-container, .marches_table .dash-spreadsheet-inner"
)
.forEach((e) => (e.style.overflow = "visible"));
document.querySelectorAll(".marches_table th.dash-header").forEach((e) => {
e.style.position = "sticky";
e.style.top = "0";
e.style.zIndex = "10";
e.style.background = "#fff";
});
Scroller verticalement la page → les en-têtes restent-ils collés en haut ?
- Step 4 : Consigner le verdict
Ajouter une section « Verdict du spike » à la spec, répondant à :
-
sélecteur exact de l'en-tête et du conteneur scrollable ;
-
l'astuce
overflow: visiblesur les conteneurs Dash suffit-elle à faire fonctionner le sticky page ? (attendu : oui) ; -
si non : noter que les en-têtes sticky devront être pilotés en JS (repositionnement au scroll) — le reste du plan reste valable, seul l'implémentation du sticky de la Task 2 change.
-
Step 5 : Commit
git add docs/superpowers/specs/2026-06-23-tableaux-scroll-horizontal-sticky-design.md
git commit -m "docs: verdict spike DOM tableaux #82
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
Task 2 : En-têtes collants (CSS)
But : les en-têtes de colonnes restent visibles en haut de la fenêtre pendant le défilement vertical de la page.
Files:
- Modify:
src/assets/css/style.css(ajouter après le bloc.marches_table.stuck, vers la ligne 360)
Interfaces:
-
Consumes: sélecteurs confirmés au spike (Task 1) :
.marches_table th.dash-header, conteneurs.dash-spreadsheet-container/.dash-spreadsheet-inner. -
Produces: classe
marches_tabledont les conteneurs Dash internes sont enoverflow: visibleet dont les en-têtes sont sticky — la Task 3 (JS) s'appuie dessus pour poser le conteneur scrollable et la barre miroir. -
Step 1 : Écrire le CSS des en-têtes sticky
Dans src/assets/css/style.css, ajouter :
/* ===== Tableaux : en-têtes collants + scroll horizontal (#82) ===== */
/* Neutraliser l'overflow interne de Dash pour que le sticky se cale sur la page */
.marches_table .dash-spreadsheet-container,
.marches_table .dash-spreadsheet-inner {
overflow: visible !important;
}
/* En-têtes de colonnes collants en haut de la fenêtre */
.marches_table th.dash-header {
position: sticky;
top: 0;
z-index: 10;
background-color: #fff;
}
Si le verdict du spike indique que le sticky CSS ne tient pas, remplacer ce bloc par le repositionnement JS décrit dans la spec et le porter en Task 3 ; documenter le choix dans le commit.
- Step 2 : Vérifier dans le navigateur
App lancée (python run.py), sur /tableau : scroller verticalement → l'en-tête reste figé en haut, sur fond opaque, au-dessus des lignes. Vérifier aussi /acheteur et /observatoire.
- Step 3 : Commit
git add src/assets/css/style.css
git commit -m "feat(tableaux): en-têtes de colonnes collants #82
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
Task 3 : Barre de défilement miroir (JS + CSS)
But : une barre de défilement horizontale toujours visible en haut du tableau, synchronisée avec le défilement horizontal du tableau, masquée si le tableau ne déborde pas.
Files:
- Create:
src/assets/table_hscroll.js - Modify:
src/assets/css/style.css(styles de.dt-hscroll)
Interfaces:
-
Consumes: les
.marches_tablerendues par Dash ; le conteneur scrollable interne (.dash-spreadsheet-container, confirmé au spike) dont on litscrollWidth/clientWidthet qu'on pilote viascrollLeft. -
Produces: pour chaque
.marches_table, un nœud<div class="dt-hscroll">inséré en première position, contenant<div class="dt-hscroll-inner">dont la largeur = largeur totale du tableau. -
Step 1 : Écrire le CSS de la barre miroir
Dans src/assets/css/style.css, à la suite du bloc de la Task 2 :
/* Barre de défilement horizontale miroir, collée en haut */
.marches_table .dt-hscroll {
position: sticky;
top: 0;
z-index: 11; /* au-dessus des en-têtes sticky */
overflow-x: auto;
overflow-y: hidden;
height: 14px;
}
.marches_table .dt-hscroll-inner {
height: 1px;
}
.marches_table .dt-hscroll.is-hidden {
display: none;
}
- Step 2 : Écrire le JS de synchronisation
Créer src/assets/table_hscroll.js :
// Barre de défilement horizontale miroir pour les tableaux (.marches_table) — #82
(function () {
"use strict";
// Renvoie le conteneur réellement scrollable horizontalement du tableau.
function getScrollEl(wrapper) {
return wrapper.querySelector(".dash-spreadsheet-container") || wrapper;
}
function setup(wrapper) {
if (wrapper.dataset.hscrollReady === "1") return;
const scrollEl = getScrollEl(wrapper);
if (!scrollEl) return;
const bar = document.createElement("div");
bar.className = "dt-hscroll is-hidden";
const inner = document.createElement("div");
inner.className = "dt-hscroll-inner";
bar.appendChild(inner);
wrapper.insertBefore(bar, wrapper.firstChild);
let syncing = false;
const onBar = () => {
if (syncing) return;
syncing = true;
scrollEl.scrollLeft = bar.scrollLeft;
syncing = false;
};
const onTable = () => {
if (syncing) return;
syncing = true;
bar.scrollLeft = scrollEl.scrollLeft;
syncing = false;
};
bar.addEventListener("scroll", onBar);
scrollEl.addEventListener("scroll", onTable);
const refresh = () => {
const total = scrollEl.scrollWidth;
const visible = scrollEl.clientWidth;
inner.style.width = total + "px";
bar.classList.toggle("is-hidden", total <= visible + 1);
bar.scrollLeft = scrollEl.scrollLeft;
};
// Recalcule quand le tableau change (pagination, tri, filtre, données).
const obs = new MutationObserver(() => refresh());
obs.observe(scrollEl, { childList: true, subtree: true, attributes: true });
window.addEventListener("resize", refresh);
wrapper.dataset.hscrollReady = "1";
refresh();
}
function scan() {
document.querySelectorAll(".marches_table").forEach(setup);
}
// Les tableaux apparaissent après le rendu Dash : observer le body.
const rootObs = new MutationObserver(() => scan());
rootObs.observe(document.body, { childList: true, subtree: true });
scan();
})();
- Step 3 : Vérifier dans le navigateur
App lancée, sur /tableau : une barre fine apparaît en haut du tableau ; la faire glisser déplace le tableau horizontalement, et inversement. Réduire la fenêtre / élargir → la barre apparaît/disparaît selon le débordement. Changer de page de pagination → la barre se recalcule. Vérifier que /acheteur, /titulaire, /observatoire (tableaux multiples) fonctionnent chacun indépendamment.
- Step 4 : Commit
git add src/assets/table_hscroll.js src/assets/css/style.css
git commit -m "feat(tableaux): barre de défilement horizontale miroir synchronisée #82
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
Task 4 : Test Selenium de non-régression
But : garantir que la barre miroir est rendue et que les en-têtes sont sticky, et que rien ne casse le chargement de /tableau.
Files:
- Create:
tests/test_tableau_hscroll.py
Interfaces:
-
Consumes: éléments DOM produits par Task 2 (
th.dash-headersticky) et Task 3 (.marches_table .dt-hscroll). -
Step 1 : Écrire le test
Créer tests/test_tableau_hscroll.py (s'aligner sur le style des tests existants dans tests/, notamment l'usage de dash_duo/DashComposite et l'import de l'app) :
from selenium.webdriver.common.by import By
def test_tableau_hscroll_bar_present(dash_duo, start_app):
"""La barre miroir est injectée et l'en-tête est collant sur /tableau."""
dash_duo.wait_for_element(".marches_table", timeout=20)
# Barre miroir injectée par table_hscroll.js
dash_duo.wait_for_element(".marches_table .dt-hscroll", timeout=10)
header = dash_duo.find_element(".marches_table th.dash-header")
position = header.value_of_css_property("position")
assert position == "sticky"
Adapter les fixtures (
dash_duo,start_app, navigation initiale vers/tableau) à ce qui existe déjà danstests/: reprendre le mécanisme d'amorçage utilisé par les autres tests (par ex.tests/test_main.py) plutôt que d'en inventer un.
- Step 2 : Lancer le test et vérifier qu'il échoue si on retire le JS (sanity)
rtk pytest tests/test_tableau_hscroll.py -v
Expected: PASS avec le JS/CSS en place.
- Step 3 : Lancer la suite Selenium impactée
rtk pytest tests/test_main.py -v
Expected: pas de régression (mêmes résultats qu'avant la branche).
- Step 4 : Commit
git add tests/test_tableau_hscroll.py
git commit -m "test(tableaux): barre miroir et en-tête sticky sur /tableau #82
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
Self-Review
Couverture spec :
- En-têtes sticky → Task 2. ✓
- Barre miroir synchronisée en haut → Task 3. ✓
- Masquage si pas de débordement → Task 3 (
is-hidden). ✓ - Recalcul pagination/tri/filtre/resize → Task 3 (MutationObserver + resize). ✓
- Plusieurs tableaux par page → Task 3 (
querySelectorAll+dataset.hscrollReady). ✓ - Contrainte overflow CSS → Task 1 (spike) + Task 2 (
overflow: visible). ✓ - Portée 4 pages via
marches_table→ ciblage CSS/JS global. ✓ - Validation navigateur + non-régression Selenium → Task 1/2/3 (manuel) + Task 4. ✓
Placeholders : les renvois « adapter aux fixtures existantes » de la Task 4 pointent vers tests/test_main.py comme référence concrète (les fixtures Selenium du projet ne sont pas réinventées ici à dessein) ; aucun TODO/TBD ailleurs.
Cohérence des noms : classes dt-hscroll / dt-hscroll-inner / is-hidden et flag dataset.hscrollReady utilisés de façon identique entre CSS (Task 3 step 1) et JS (Task 3 step 2). marches_table et th.dash-header cohérents entre Task 2 et Task 4.