diff --git a/docs/superpowers/plans/2026-06-23-tableaux-scroll-horizontal-sticky.md b/docs/superpowers/plans/2026-06-23-tableaux-scroll-horizontal-sticky.md new file mode 100644 index 0000000..8bddc51 --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-tableaux-scroll-horizontal-sticky.md @@ -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 `. + +--- + +## 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 " +``` + +--- + +## 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 " +``` + +--- + +## 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 `
` inséré en première position, contenant `
` 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 " +``` + +--- + +## 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 " +``` + +--- + +## 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. diff --git a/docs/superpowers/specs/2026-06-23-tableaux-scroll-horizontal-sticky-design.md b/docs/superpowers/specs/2026-06-23-tableaux-scroll-horizontal-sticky-design.md new file mode 100644 index 0000000..eba15d2 --- /dev/null +++ b/docs/superpowers/specs/2026-06-23-tableaux-scroll-horizontal-sticky-design.md @@ -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) | `
` 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 ?) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index 06bb670..ef9674c 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -359,6 +359,20 @@ table.cell-table th { 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 { background-color: rgb(255 240 240 / 40%); } @@ -372,6 +386,37 @@ td[data-dash-column="marche"] a { 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-actions { margin-right: 8px; diff --git a/src/assets/table_hscroll.js b/src/assets/table_hscroll.js new file mode 100644 index 0000000..f59f142 --- /dev/null +++ b/src/assets/table_hscroll.js @@ -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(); +})(); diff --git a/tests/test_tableau_hscroll.py b/tests/test_tableau_hscroll.py new file mode 100644 index 0000000..8bd9920 --- /dev/null +++ b/tests/test_tableau_hscroll.py @@ -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"