feat(tableaux): scroll horizontal ergonomique + barre custom (#82)
- Barre de défilement JS orange (12px) injectée en haut de chaque .marches_table, collée avec position:sticky, masquée si le tableau tient dans le viewport - Scroll contenu dans .dash-spreadsheet-container (overflow-x:hidden) → plus de scrollbar navigateur en bas de page - Drag souris + tactile, clic sur le track, molette/trackpad - Recalcul automatique au resize et aux re-renders Dash (MutationObserver) - Test Selenium de non-régression (overflow-x:hidden sur le conteneur) Sticky headers abandonnés (incompatibles avec scroll contenu). Portée : 4 pages via la classe partagée .marches_table. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,350 @@
|
|||||||
|
# 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`), jamais `figures`.
|
||||||
|
- 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) et `ruff` (Python) et **modifie les fichiers** : après un échec dû au reformatage, refaire `git add` puis 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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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-header` dans un `tr`).
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Tester l'hypothèse sticky en live**
|
||||||
|
|
||||||
|
Dans la console DevTools, appliquer à chaud :
|
||||||
|
|
||||||
|
```js
|
||||||
|
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: visible` sur 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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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_table` dont les conteneurs Dash internes sont en `overflow: visible` et 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 :
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* ===== 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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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_table` rendues par Dash ; le conteneur scrollable interne (`.dash-spreadsheet-container`, confirmé au spike) dont on lit `scrollWidth`/`clientWidth` et qu'on pilote via `scrollLeft`.
|
||||||
|
- 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 :
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* 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` :
|
||||||
|
|
||||||
|
```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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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-header` sticky) 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) :
|
||||||
|
|
||||||
|
```python
|
||||||
|
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à dans `tests/` : 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)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rtk pytest tests/test_tableau_hscroll.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS avec le JS/CSS en place.
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Lancer la suite Selenium impactée**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rtk pytest tests/test_main.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: pas de régression (mêmes résultats qu'avant la branche).
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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.
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# Défilement horizontal ergonomique des tableaux (#82)
|
||||||
|
|
||||||
|
## Problème
|
||||||
|
|
||||||
|
Les tableaux de données (`DataTable` Dash) sont souvent plus larges que l'écran et
|
||||||
|
**débordent vers la droite**. Aujourd'hui aucun `overflowX` n'est défini sur leur
|
||||||
|
conteneur : le tableau étire la page entière, et le seul moyen de faire défiler
|
||||||
|
horizontalement est la **barre de défilement de la fenêtre du navigateur**, tout en
|
||||||
|
bas du viewport. Cette barre :
|
||||||
|
|
||||||
|
- est discrète et fait défiler **toute la page** (pas seulement le tableau) ;
|
||||||
|
- n'est pas comprise comme « le moyen de voir le reste du tableau ».
|
||||||
|
|
||||||
|
De plus, les tableaux dépassent souvent du bas de l'écran : une barre placée en bas
|
||||||
|
du tableau serait invisible sans scroller.
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Rendre le défilement horizontal **évident et toujours accessible**, et garder les
|
||||||
|
**en-têtes de colonnes visibles** pendant le défilement vertical, sans introduire de
|
||||||
|
zone scrollable imbriquée gênante.
|
||||||
|
|
||||||
|
## Approche retenue (option B — sticky au niveau page)
|
||||||
|
|
||||||
|
Le tableau **reste dans le flux de la page** (pas de conteneur à hauteur fixe, pas de
|
||||||
|
scroll imbriqué). On combine deux mécanismes :
|
||||||
|
|
||||||
|
1. **En-têtes collants** — `position: sticky; top: 0` sur la ligne d'en-tête du
|
||||||
|
tableau. Quand l'utilisateur descend dans la page, les en-têtes se figent en haut
|
||||||
|
de la fenêtre au lieu d'être « avalés ».
|
||||||
|
|
||||||
|
2. **Barre de défilement horizontale miroir en haut** — un petit élément placé
|
||||||
|
juste au-dessus du tableau, lui aussi `sticky` en haut, dont le défilement
|
||||||
|
horizontal est **synchronisé** avec celui du tableau. Elle est donc toujours
|
||||||
|
visible dès qu'on voit le haut du tableau, et pilote le défilement horizontal sans
|
||||||
|
devoir descendre en bas du tableau.
|
||||||
|
|
||||||
|
La barre miroir et les en-têtes collants se figent ensemble en haut de la fenêtre :
|
||||||
|
l'utilisateur garde en permanence le repère des colonnes **et** le contrôle du
|
||||||
|
défilement horizontal.
|
||||||
|
|
||||||
|
### Pourquoi pas l'option A (tableau « fenêtré » à hauteur fixe)
|
||||||
|
|
||||||
|
Écartée volontairement : un conteneur à hauteur fixe avec scroll interne crée un
|
||||||
|
**scroll imbriqué** (la molette agit d'abord sur le tableau, pas sur la page), source
|
||||||
|
de confusion. L'option B garde un comportement de défilement vertical unique (celui
|
||||||
|
de la page) ; seul le défilement horizontal est « custom ».
|
||||||
|
|
||||||
|
## Contrainte technique CSS à gérer
|
||||||
|
|
||||||
|
Un conteneur en `overflow-x: auto` devient automatiquement un conteneur de
|
||||||
|
défilement **vertical** (règle CSS : `overflow-y: visible` recalculé en `auto` dès
|
||||||
|
que l'autre axe n'est pas `visible`), ce qui **casse** le `position: sticky; top: 0`
|
||||||
|
des en-têtes par rapport à la page.
|
||||||
|
|
||||||
|
Conséquences pour l'implémentation :
|
||||||
|
|
||||||
|
- Le **défilement horizontal réel** doit se faire dans un conteneur dédié en
|
||||||
|
`overflow-x: auto` ; mais ce conteneur ne peut pas, en même temps, héberger des
|
||||||
|
en-têtes sticky « page ». La barre miroir du haut résout ce conflit : c'est **elle**
|
||||||
|
qui porte le `overflow-x: auto`, séparée du tableau, et synchronisée par JS.
|
||||||
|
- Il faudra **vérifier et neutraliser au besoin l'`overflow` interne** que Dash
|
||||||
|
DataTable applique à ses propres conteneurs (`.dash-spreadsheet-container`,
|
||||||
|
`.dash-spreadsheet-inner`) pour que le sticky des en-têtes fonctionne.
|
||||||
|
- Le rendu réel de Dash DataTable doit être inspecté avant de figer le CSS : la
|
||||||
|
structure DOM exacte (où poser `sticky`, quel élément porte la largeur totale)
|
||||||
|
conditionne la solution. **À valider en testant dans le navigateur.**
|
||||||
|
|
||||||
|
## Composants
|
||||||
|
|
||||||
|
| Élément | Rôle | Emplacement probable |
|
||||||
|
| --------------------- | --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||||
|
| CSS en-têtes sticky | `position: sticky; top: 0` sur la ligne d'en-tête, z-index, fond opaque | `src/assets/css/style.css` (cible `.marches_table`) |
|
||||||
|
| Barre miroir (markup) | `<div>` scrollable au-dessus du tableau, avec un enfant à la largeur du tableau | composant partagé `DataTable` / wrapper dans `src/figures.py` |
|
||||||
|
| Synchronisation JS | lier scrollLeft barre ↔ tableau ; recopier la largeur du tableau dans la barre ; recalcul au resize / changement de données | nouveau fichier `src/assets/js/*.js` (assets Dash, chargé automatiquement) |
|
||||||
|
| CSS barre miroir | hauteur, sticky `top: 0`, masquage si pas de débordement | `src/assets/css/style.css` |
|
||||||
|
|
||||||
|
## Portée
|
||||||
|
|
||||||
|
Les **quatre pages** utilisant `className="marches_table"` :
|
||||||
|
`/tableau`, `/acheteur`, `/titulaire`, `/observatoire`. La solution passe par le
|
||||||
|
composant `DataTable` partagé et la classe CSS `marches_table`, donc l'effort est
|
||||||
|
quasi identique pour une ou quatre pages.
|
||||||
|
|
||||||
|
## Flux de données / interactions
|
||||||
|
|
||||||
|
1. Au rendu (et à chaque changement de données / largeur de fenêtre), le JS mesure la
|
||||||
|
largeur totale du tableau et la reporte dans l'élément interne de la barre miroir →
|
||||||
|
la barre miroir affiche une glissière proportionnelle.
|
||||||
|
2. Événement `scroll` sur la barre miroir → on applique `scrollLeft` au conteneur du
|
||||||
|
tableau ; et inversement (scroll du tableau → barre miroir), avec garde anti-boucle.
|
||||||
|
3. Si le tableau ne déborde pas, la barre miroir est masquée.
|
||||||
|
|
||||||
|
## Cas limites
|
||||||
|
|
||||||
|
- **Pas de débordement** : barre miroir masquée, en-têtes sticky inoffensifs.
|
||||||
|
- **Pagination / re-render** (pages en `page_action="custom"`) : la largeur peut
|
||||||
|
changer → la synchro doit se recalculer après mise à jour des données.
|
||||||
|
- **Resize de la fenêtre** : recalcul de la largeur miroir.
|
||||||
|
- **Plusieurs tableaux sur une page** (`/observatoire`, `/titulaire`, `/acheteur` ont
|
||||||
|
plusieurs `marches_table`) : le JS doit gérer chaque tableau indépendamment.
|
||||||
|
- **Persistance / tri / filtre** : ne doit pas casser la synchro (réattacher les
|
||||||
|
écouteurs si le DOM est recréé).
|
||||||
|
|
||||||
|
## Tests / validation
|
||||||
|
|
||||||
|
- Vérification **manuelle dans le navigateur** (point critique vu l'incertitude sur le
|
||||||
|
DOM de DataTable) : débordement horizontal sur `/tableau`, sticky des en-têtes en
|
||||||
|
scrollant, synchro des deux barres, comportement sur les pages à tableaux multiples.
|
||||||
|
- S'assurer que les tests Selenium existants ne régressent pas
|
||||||
|
(`pytest tests/test_main.py`).
|
||||||
|
|
||||||
|
## Hors périmètre (YAGNI)
|
||||||
|
|
||||||
|
- Colonnes figées (1re colonne sticky horizontalement).
|
||||||
|
- Réduction du nombre de colonnes par défaut / refonte du sélecteur de colonnes.
|
||||||
|
- `overscroll-behavior` et zones scrollables imbriquées (option A écartée).
|
||||||
|
|
||||||
|
## Verdict du spike
|
||||||
|
|
||||||
|
Le spike (Steps 1–3 exécutés par l'utilisateur en DevTools) doit valider les points suivants. En cas de déviation, le reste du plan reste applicable ; seule l'implémentation du sticky (Task 2) ajustera sa stratégie.
|
||||||
|
|
||||||
|
### Éléments attendus du DOM
|
||||||
|
|
||||||
|
- **Conteneur scrollable** : `.dash-spreadsheet-container` (enfant direct de `.marches_table`)
|
||||||
|
- **Conteneur interne** : `.dash-spreadsheet-inner` (enfant de `.dash-spreadsheet-container`) — peut aussi porter un `overflow` interne
|
||||||
|
- **En-têtes** : sélecteur exact `th.dash-header` (dans un `tr` au sein du tableau)
|
||||||
|
- **Table complète** : `.cell-table` avec ses dimensions (`scrollWidth` >> `clientWidth` du parent → débordement confirmé)
|
||||||
|
|
||||||
|
### Hypothèse sticky
|
||||||
|
|
||||||
|
L'astuce CSS consiste à :
|
||||||
|
|
||||||
|
1. Neutraliser l'`overflow` sur `.dash-spreadsheet-container` et `.dash-spreadsheet-inner` en les ramenant à `overflow: visible` (ou en supprimant le style si possible)
|
||||||
|
2. Appliquer `position: sticky; top: 0; z-index: 10; background: #fff` aux en-têtes `th.dash-header`
|
||||||
|
|
||||||
|
**Verdict attendu :** ✅ Oui — les en-têtes restent collés au haut de la fenêtre quand on scroll verticalement la page, sans recours à du JS supplémentaire (hormis la synchro scrollLeft pour le miroir).
|
||||||
|
|
||||||
|
**Si verdict = ❌ Non :** les en-têtes seront pilotés entièrement en JS (repositionnement au scroll), avec synchronisation du scroll vertical. Le reste du plan (barre miroir, synchro horizontale) reste valable.
|
||||||
|
|
||||||
|
### Références (à noter lors du spike)
|
||||||
|
|
||||||
|
- `scrollWidth` et `clientWidth` de la table vs. ses parents
|
||||||
|
- Styles `overflow` en _Computed_ sur `.dash-spreadsheet-container`, `.dash-spreadsheet-inner` et `.cell-table`
|
||||||
|
- Résultat du test d'hypothèse JS (sticky page fonctionne-t-il ?)
|
||||||
@@ -359,6 +359,20 @@ table.cell-table th {
|
|||||||
right: 200px;
|
right: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Tableaux : en-têtes collants + scroll horizontal (#82) ===== */
|
||||||
|
|
||||||
|
/* Contenir le scroll horizontal dans le conteneur Dash (élimine la scrollbar native en bas de page) */
|
||||||
|
/* overflow-y:clip évite la conversion CSS visible→auto qui ajouterait une scrollbar verticale */
|
||||||
|
.marches_table .dash-spreadsheet-container {
|
||||||
|
overflow-x: hidden !important;
|
||||||
|
overflow-y: clip !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* L'inner reste visible pour que le tableau se déploie librement en largeur */
|
||||||
|
.marches_table .dash-spreadsheet-inner {
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
.marches_table .cell-table tr:nth-child(even) td {
|
.marches_table .cell-table tr:nth-child(even) td {
|
||||||
background-color: rgb(255 240 240 / 40%);
|
background-color: rgb(255 240 240 / 40%);
|
||||||
}
|
}
|
||||||
@@ -372,6 +386,37 @@ td[data-dash-column="marche"] a {
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Barre de défilement horizontale, collée en haut du tableau */
|
||||||
|
.marches_table .dt-hscroll {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 11;
|
||||||
|
height: 12px;
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marches_table .dt-hscroll.is-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thumb custom — toujours visible, draggable */
|
||||||
|
.marches_table .dt-hscroll-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
height: calc(100% - 4px);
|
||||||
|
min-width: 40px;
|
||||||
|
background-color: orange;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marches_table .dt-hscroll-thumb:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
/* Column Visibility Menu */
|
/* Column Visibility Menu */
|
||||||
.column-actions {
|
.column-actions {
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// Barre de défilement horizontale pour les tableaux (.marches_table) — #82
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
function setup(wrapper) {
|
||||||
|
if (wrapper.dataset.hscrollReady === "1") return;
|
||||||
|
|
||||||
|
const dashContainer = wrapper.querySelector(".dash-spreadsheet-container");
|
||||||
|
if (!dashContainer) return;
|
||||||
|
|
||||||
|
// Garde posée après la vérification de dashContainer, avant toute manipulation DOM
|
||||||
|
// qui déclencherait rootObs et provoquerait une re-entrée dans setup().
|
||||||
|
wrapper.dataset.hscrollReady = "1";
|
||||||
|
|
||||||
|
const bar = document.createElement("div");
|
||||||
|
bar.className = "dt-hscroll is-hidden";
|
||||||
|
const thumb = document.createElement("div");
|
||||||
|
thumb.className = "dt-hscroll-thumb";
|
||||||
|
bar.appendChild(thumb);
|
||||||
|
wrapper.insertBefore(bar, wrapper.firstChild);
|
||||||
|
|
||||||
|
// Métriques basées sur le conteneur scrollable (pas la page).
|
||||||
|
// dashContainer a overflow-x:hidden → scrollLeft est contrôlable par JS.
|
||||||
|
const metrics = () => {
|
||||||
|
const total = dashContainer.scrollWidth;
|
||||||
|
const visible = dashContainer.clientWidth;
|
||||||
|
const thumbW = Math.max(40, (visible / total) * visible);
|
||||||
|
const scrollRange = total - visible;
|
||||||
|
const thumbRange = visible - thumbW;
|
||||||
|
return { total, visible, thumbW, scrollRange, thumbRange };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mise à jour de la position du thumb selon dashContainer.scrollLeft.
|
||||||
|
const syncThumb = () => {
|
||||||
|
const { total, visible, thumbW, scrollRange, thumbRange } = metrics();
|
||||||
|
if (total <= visible + 1 || thumbRange <= 0) return;
|
||||||
|
thumb.style.width = thumbW + "px";
|
||||||
|
const fraction =
|
||||||
|
scrollRange > 0 ? dashContainer.scrollLeft / scrollRange : 0;
|
||||||
|
thumb.style.left = Math.round(fraction * thumbRange) + "px";
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Drag souris + tactile ---
|
||||||
|
let dragStartX = null;
|
||||||
|
let dragScrollStart = null;
|
||||||
|
|
||||||
|
const startDrag = (clientX) => {
|
||||||
|
dragStartX = clientX;
|
||||||
|
dragScrollStart = dashContainer.scrollLeft;
|
||||||
|
};
|
||||||
|
const moveDrag = (clientX) => {
|
||||||
|
if (dragStartX === null) return;
|
||||||
|
const dx = clientX - dragStartX;
|
||||||
|
const { scrollRange, thumbRange } = metrics();
|
||||||
|
if (thumbRange <= 0) return;
|
||||||
|
dashContainer.scrollLeft = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(scrollRange, dragScrollStart + (dx / thumbRange) * scrollRange)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const endDrag = () => {
|
||||||
|
dragStartX = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
thumb.addEventListener("mousedown", (e) => {
|
||||||
|
startDrag(e.clientX);
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
document.addEventListener("mousemove", (e) => moveDrag(e.clientX));
|
||||||
|
document.addEventListener("mouseup", endDrag);
|
||||||
|
|
||||||
|
thumb.addEventListener(
|
||||||
|
"touchstart",
|
||||||
|
(e) => {
|
||||||
|
startDrag(e.touches[0].clientX);
|
||||||
|
e.preventDefault();
|
||||||
|
},
|
||||||
|
{ passive: false }
|
||||||
|
);
|
||||||
|
document.addEventListener(
|
||||||
|
"touchmove",
|
||||||
|
(e) => {
|
||||||
|
if (dragStartX !== null) {
|
||||||
|
moveDrag(e.touches[0].clientX);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ passive: false }
|
||||||
|
);
|
||||||
|
document.addEventListener("touchend", endDrag);
|
||||||
|
|
||||||
|
// Clic sur le track (hors thumb) : saute à la position cliquée.
|
||||||
|
bar.addEventListener("click", (e) => {
|
||||||
|
if (e.target === thumb) return;
|
||||||
|
const rect = bar.getBoundingClientRect();
|
||||||
|
const { scrollRange, thumbW, thumbRange } = metrics();
|
||||||
|
const fraction = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(1, (e.clientX - rect.left - thumbW / 2) / thumbRange)
|
||||||
|
);
|
||||||
|
dashContainer.scrollLeft = fraction * scrollRange;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scroll molette/trackpad horizontal → redirigé vers le conteneur.
|
||||||
|
// SPA : ce listener est intentionnellement conservé pour toute la durée de vie de la page.
|
||||||
|
wrapper.addEventListener(
|
||||||
|
"wheel",
|
||||||
|
(e) => {
|
||||||
|
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
dashContainer.scrollLeft = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(
|
||||||
|
dashContainer.scrollWidth - dashContainer.clientWidth,
|
||||||
|
dashContainer.scrollLeft + e.deltaX
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ passive: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Synchronise le thumb quand le conteneur défile (drag, wheel, ou autre).
|
||||||
|
// SPA : ce listener est intentionnellement conservé pour toute la durée de vie de la page.
|
||||||
|
dashContainer.addEventListener("scroll", syncThumb);
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
const hasOverflow =
|
||||||
|
dashContainer.scrollWidth > dashContainer.clientWidth + 1;
|
||||||
|
bar.classList.toggle("is-hidden", !hasOverflow);
|
||||||
|
if (hasOverflow) syncThumb();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Recalcule quand le tableau change (pagination, tri, filtre, données).
|
||||||
|
const obs = new MutationObserver(() => refresh());
|
||||||
|
obs.observe(dashContainer, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
attributes: true,
|
||||||
|
});
|
||||||
|
// SPA : ce listener est intentionnellement conservé pour toute la durée de vie de la page.
|
||||||
|
window.addEventListener("resize", refresh);
|
||||||
|
|
||||||
|
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();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from dash.testing.composite import DashComposite
|
||||||
|
|
||||||
|
|
||||||
|
def test_tableau_hscroll_bar_present(dash_duo: DashComposite):
|
||||||
|
"""La barre de défilement est injectée et le conteneur scroll horizontalement."""
|
||||||
|
from src.app import app
|
||||||
|
|
||||||
|
dash_duo.start_server(app)
|
||||||
|
dash_duo.wait_for_page(f"{dash_duo.server_url}/tableau")
|
||||||
|
dash_duo.wait_for_element(".marches_table", timeout=20)
|
||||||
|
# Barre injectée par table_hscroll.js
|
||||||
|
dash_duo.wait_for_element(".marches_table .dt-hscroll", timeout=10)
|
||||||
|
dash_duo.wait_for_element(".marches_table .dt-hscroll-thumb", timeout=5)
|
||||||
|
|
||||||
|
# Le conteneur Dash doit avoir overflow-x:hidden (scroll contenu, pas de scrollbar page)
|
||||||
|
container = dash_duo.find_element(".marches_table .dash-spreadsheet-container")
|
||||||
|
overflow = container.value_of_css_property("overflow-x")
|
||||||
|
assert overflow == "hidden"
|
||||||
Reference in New Issue
Block a user