From 0ed807f98ce0eda1a4ab76eebe7e31b6ce36efa2 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 15:08:47 +0200 Subject: [PATCH 01/18] =?UTF-8?q?docs:=20design=20scroll=20horizontal=20+?= =?UTF-8?q?=20en-t=C3=AAtes=20sticky=20des=20tableaux=20#82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- ...ableaux-scroll-horizontal-sticky-design.md | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-23-tableaux-scroll-horizontal-sticky-design.md 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..071e7b7 --- /dev/null +++ b/docs/superpowers/specs/2026-06-23-tableaux-scroll-horizontal-sticky-design.md @@ -0,0 +1,117 @@ +# 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). From 68bd398759e08a8e04b9cb13f60b11761be4d165 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 15:14:03 +0200 Subject: [PATCH 02/18] =?UTF-8?q?docs:=20plan=20d'impl=C3=A9mentation=20sc?= =?UTF-8?q?roll=20horizontal=20tableaux=20#82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- ...06-23-tableaux-scroll-horizontal-sticky.md | 350 ++++++++++++++++++ 1 file changed, 350 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-23-tableaux-scroll-horizontal-sticky.md 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. From ec2b8389c3c7b57660a0b4a65a22249a116fe0bd Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 15:44:11 +0200 Subject: [PATCH 03/18] docs: verdict spike DOM tableaux #82 Co-Authored-By: Claude Opus 4.8 --- ...ableaux-scroll-horizontal-sticky-design.md | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) 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 index 071e7b7..eba15d2 100644 --- 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 @@ -115,3 +115,31 @@ quasi identique pour une ou quatre pages. - 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 ?) From 4be82ac76b0f6fb4834a0fedab3f5d52946432dc Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 15:57:35 +0200 Subject: [PATCH 04/18] =?UTF-8?q?feat(tableaux):=20en-t=C3=AAtes=20de=20co?= =?UTF-8?q?lonnes=20collants=20#82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- src/assets/css/style.css | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index ebe7ecb..87cd9df 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -359,6 +359,22 @@ table.cell-table th { right: 200px; } +/* ===== 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; +} + .marches_table .cell-table tr:nth-child(even) td { background-color: rgb(255 240 240 / 40%); } From cb1685d0552a29a7203f6c76980e4f92e01762d8 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 16:00:45 +0200 Subject: [PATCH 05/18] =?UTF-8?q?feat(tableaux):=20barre=20de=20d=C3=A9fil?= =?UTF-8?q?ement=20horizontale=20miroir=20synchronis=C3=A9e=20#82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- src/assets/css/style.css | 18 +++++++++++ src/assets/table_hscroll.js | 63 +++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 src/assets/table_hscroll.js diff --git a/src/assets/css/style.css b/src/assets/css/style.css index 87cd9df..c8a188c 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -379,6 +379,24 @@ table.cell-table th { background-color: rgb(255 240 240 / 40%); } +/* 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; +} + /* 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..626ae27 --- /dev/null +++ b/src/assets/table_hscroll.js @@ -0,0 +1,63 @@ +// 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(); +})(); From 2413ffb4f01d277ebd0d881dfc1f229f23b66d8d Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 16:05:49 +0200 Subject: [PATCH 06/18] =?UTF-8?q?test(tableaux):=20barre=20miroir=20et=20e?= =?UTF-8?q?n-t=C3=AAte=20sticky=20sur=20/tableau=20#82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- tests/test_tableau_hscroll.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 tests/test_tableau_hscroll.py diff --git a/tests/test_tableau_hscroll.py b/tests/test_tableau_hscroll.py new file mode 100644 index 0000000..6699c89 --- /dev/null +++ b/tests/test_tableau_hscroll.py @@ -0,0 +1,16 @@ +from dash.testing.composite import DashComposite + + +def test_tableau_hscroll_bar_present(dash_duo: DashComposite): + """La barre miroir est injectée et l'en-tête est collant sur /tableau.""" + 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 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" From ce4b06fe3df6ee0f9dadc66091b8a1849b6e22ec Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 16:14:20 +0200 Subject: [PATCH 07/18] refactor(tableaux): commentaires et nettoyage table_hscroll.js #82 Co-Authored-By: Claude Opus 4.8 --- src/assets/table_hscroll.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/assets/table_hscroll.js b/src/assets/table_hscroll.js index 626ae27..8cb8085 100644 --- a/src/assets/table_hscroll.js +++ b/src/assets/table_hscroll.js @@ -10,7 +10,6 @@ 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"; @@ -19,6 +18,8 @@ bar.appendChild(inner); wrapper.insertBefore(bar, wrapper.firstChild); + // syncing évite les boucles dans la même pile d'exécution ; l'affectation de scrollLeft + // déclenche l'événement scroll de façon synchrone dans Chromium. let syncing = false; const onBar = () => { if (syncing) return; @@ -39,6 +40,7 @@ const total = scrollEl.scrollWidth; const visible = scrollEl.clientWidth; inner.style.width = total + "px"; + // +1 absorbe les erreurs d'arrondi sous-pixel bar.classList.toggle("is-hidden", total <= visible + 1); bar.scrollLeft = scrollEl.scrollLeft; }; @@ -46,6 +48,7 @@ // Recalcule quand le tableau change (pagination, tri, filtre, données). const obs = new MutationObserver(() => refresh()); obs.observe(scrollEl, { 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); wrapper.dataset.hscrollReady = "1"; From 96c0b0dd356bcf8a7b5a227f93dc85f3889d7501 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 16:52:22 +0200 Subject: [PATCH 08/18] fix(tableaux): scroll horizontal via wrapper overflow-x auto #82 Co-Authored-By: Claude Opus 4.8 --- src/assets/css/style.css | 8 ++++++++ src/assets/table_hscroll.js | 38 ++++++++++++++++++++++--------------- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index c8a188c..a98f098 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -397,6 +397,14 @@ table.cell-table th { display: none; } +/* Conteneur scrollable horizontal — overflow-y:clip ne crée pas de scroll container vertical */ +/* donc position:sticky sur th.dash-header reste calé sur la page */ +.marches_table .dt-scroll-wrapper { + overflow-x: auto; + overflow-y: clip; + width: 100%; +} + /* Column Visibility Menu */ .column-actions { margin-right: 8px; diff --git a/src/assets/table_hscroll.js b/src/assets/table_hscroll.js index 8cb8085..829db34 100644 --- a/src/assets/table_hscroll.js +++ b/src/assets/table_hscroll.js @@ -1,22 +1,26 @@ -// Barre de défilement horizontale miroir pour les tableaux (.marches_table) — #82 +// Barre de défilement horizontale 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); + const dashContainer = wrapper.querySelector(".dash-spreadsheet-container"); + if (!dashContainer) return; + + // Injecter le conteneur scrollable horizontal autour du conteneur Dash + const scrollWrapper = document.createElement("div"); + scrollWrapper.className = "dt-scroll-wrapper"; + dashContainer.parentNode.insertBefore(scrollWrapper, dashContainer); + scrollWrapper.appendChild(dashContainer); + + // Injecter la barre de défilement en haut, avant le wrapper scrollable 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); + wrapper.insertBefore(bar, scrollWrapper); // syncing évite les boucles dans la même pile d'exécution ; l'affectation de scrollLeft // déclenche l'événement scroll de façon synchrone dans Chromium. @@ -24,30 +28,34 @@ const onBar = () => { if (syncing) return; syncing = true; - scrollEl.scrollLeft = bar.scrollLeft; + scrollWrapper.scrollLeft = bar.scrollLeft; syncing = false; }; const onTable = () => { if (syncing) return; syncing = true; - bar.scrollLeft = scrollEl.scrollLeft; + bar.scrollLeft = scrollWrapper.scrollLeft; syncing = false; }; bar.addEventListener("scroll", onBar); - scrollEl.addEventListener("scroll", onTable); + scrollWrapper.addEventListener("scroll", onTable); const refresh = () => { - const total = scrollEl.scrollWidth; - const visible = scrollEl.clientWidth; + const total = scrollWrapper.scrollWidth; + const visible = scrollWrapper.clientWidth; inner.style.width = total + "px"; // +1 absorbe les erreurs d'arrondi sous-pixel bar.classList.toggle("is-hidden", total <= visible + 1); - bar.scrollLeft = scrollEl.scrollLeft; + bar.scrollLeft = scrollWrapper.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 }); + 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); From 1d27d517f317a7ab757ccafae39f7f8fa24f2caf Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 17:33:09 +0200 Subject: [PATCH 09/18] fix(tableaux): garde hscrollReady avant manipulation DOM #82 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Évite la boucle infinie : le rootObs se déclenchait sur les insertions DOM de setup() avant que la garde ne soit posée, relançant setup() en boucle et faisant monter le CPU. Co-Authored-By: Claude Opus 4.8 --- src/assets/table_hscroll.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/assets/table_hscroll.js b/src/assets/table_hscroll.js index 829db34..908b2aa 100644 --- a/src/assets/table_hscroll.js +++ b/src/assets/table_hscroll.js @@ -4,6 +4,7 @@ function setup(wrapper) { if (wrapper.dataset.hscrollReady === "1") return; + wrapper.dataset.hscrollReady = "1"; const dashContainer = wrapper.querySelector(".dash-spreadsheet-container"); if (!dashContainer) return; @@ -59,7 +60,6 @@ // SPA : ce listener est intentionnellement conservé pour toute la durée de vie de la page. window.addEventListener("resize", refresh); - wrapper.dataset.hscrollReady = "1"; refresh(); } From 817aeedc6f20603a41f0ed17a28cd5b6688393a8 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 17:42:50 +0200 Subject: [PATCH 10/18] =?UTF-8?q?fix(tableaux):=20garde=20hscrollReady=20a?= =?UTF-8?q?pr=C3=A8s=20v=C3=A9rification=20dashContainer=20#82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Si la garde est posée avant de vérifier que dashContainer existe, un déclenchement prématuré de rootObs (avant le rendu Dash) marque le wrapper comme traité sans jamais injecter le scroll wrapper. Co-Authored-By: Claude Opus 4.8 --- src/assets/table_hscroll.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/assets/table_hscroll.js b/src/assets/table_hscroll.js index 908b2aa..29bdaf7 100644 --- a/src/assets/table_hscroll.js +++ b/src/assets/table_hscroll.js @@ -4,11 +4,14 @@ function setup(wrapper) { if (wrapper.dataset.hscrollReady === "1") return; - wrapper.dataset.hscrollReady = "1"; 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"; + // Injecter le conteneur scrollable horizontal autour du conteneur Dash const scrollWrapper = document.createElement("div"); scrollWrapper.className = "dt-scroll-wrapper"; From 099fba58f709dcca8b510d62a8b2f9eb872f3be8 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 18:22:08 +0200 Subject: [PATCH 11/18] =?UTF-8?q?fix(tableaux):=20option=20A=20=E2=80=94?= =?UTF-8?q?=20sync=20barre=20avec=20window.scrollX=20+=20style=20orange=20?= =?UTF-8?q?#82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Abandonne le wrapper overflow-x (incompatible avec position:sticky des en-têtes). La barre contrôle désormais le scroll horizontal de la page via window.scrollTo/scrollX, ce qui préserve le sticky des th. Scrollbar orange 12px, styling webkit + Firefox. Co-Authored-By: Claude Opus 4.8 --- src/assets/css/style.css | 27 +++++++++++++++++---------- src/assets/table_hscroll.js | 30 +++++++++++++----------------- 2 files changed, 30 insertions(+), 27 deletions(-) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index a98f098..c62ad16 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -379,14 +379,29 @@ table.cell-table th { background-color: rgb(255 240 240 / 40%); } -/* Barre de défilement horizontale miroir, collée en haut */ +/* Barre de défilement horizontale, collée en haut au-dessus des en-têtes */ .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; + height: 12px; + scrollbar-color: orange #e0e0e0; + scrollbar-width: thin; +} + +.marches_table .dt-hscroll::-webkit-scrollbar { + height: 12px; +} + +.marches_table .dt-hscroll::-webkit-scrollbar-thumb { + background-color: orange; + border-radius: 6px; +} + +.marches_table .dt-hscroll::-webkit-scrollbar-track { + background-color: #e0e0e0; } .marches_table .dt-hscroll-inner { @@ -397,14 +412,6 @@ table.cell-table th { display: none; } -/* Conteneur scrollable horizontal — overflow-y:clip ne crée pas de scroll container vertical */ -/* donc position:sticky sur th.dash-header reste calé sur la page */ -.marches_table .dt-scroll-wrapper { - overflow-x: auto; - overflow-y: clip; - width: 100%; -} - /* Column Visibility Menu */ .column-actions { margin-right: 8px; diff --git a/src/assets/table_hscroll.js b/src/assets/table_hscroll.js index 29bdaf7..3d518cd 100644 --- a/src/assets/table_hscroll.js +++ b/src/assets/table_hscroll.js @@ -12,45 +12,41 @@ // qui déclencherait rootObs et provoquerait une re-entrée dans setup(). wrapper.dataset.hscrollReady = "1"; - // Injecter le conteneur scrollable horizontal autour du conteneur Dash - const scrollWrapper = document.createElement("div"); - scrollWrapper.className = "dt-scroll-wrapper"; - dashContainer.parentNode.insertBefore(scrollWrapper, dashContainer); - scrollWrapper.appendChild(dashContainer); - - // Injecter la barre de défilement en haut, avant le wrapper scrollable 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, scrollWrapper); + wrapper.insertBefore(bar, wrapper.firstChild); - // syncing évite les boucles dans la même pile d'exécution ; l'affectation de scrollLeft - // déclenche l'événement scroll de façon synchrone dans Chromium. + // La barre synchronise le scroll horizontal de la page (overflow: visible sur les + // conteneurs Dash laisse le scroll se faire au niveau de la page, ce qui permet + // aux en-têtes position:sticky de rester calés sur la fenêtre). + // syncing évite les boucles dans la même pile d'exécution. let syncing = false; const onBar = () => { if (syncing) return; syncing = true; - scrollWrapper.scrollLeft = bar.scrollLeft; + window.scrollTo(bar.scrollLeft, window.scrollY); syncing = false; }; - const onTable = () => { + const onPage = () => { if (syncing) return; syncing = true; - bar.scrollLeft = scrollWrapper.scrollLeft; + bar.scrollLeft = window.scrollX; syncing = false; }; bar.addEventListener("scroll", onBar); - scrollWrapper.addEventListener("scroll", onTable); + // SPA : ce listener est intentionnellement conservé pour toute la durée de vie de la page. + window.addEventListener("scroll", onPage); const refresh = () => { - const total = scrollWrapper.scrollWidth; - const visible = scrollWrapper.clientWidth; + const total = document.documentElement.scrollWidth; + const visible = window.innerWidth; inner.style.width = total + "px"; // +1 absorbe les erreurs d'arrondi sous-pixel bar.classList.toggle("is-hidden", total <= visible + 1); - bar.scrollLeft = scrollWrapper.scrollLeft; + bar.scrollLeft = window.scrollX; }; // Recalcule quand le tableau change (pagination, tri, filtre, données). From 0edca05df5dc232d87d3ce5d03991265a1bed71e Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 18:26:36 +0200 Subject: [PATCH 12/18] =?UTF-8?q?fix(tableaux):=20barre=2016px=20toujours?= =?UTF-8?q?=20visible,=20headers=20d=C3=A9cal=C3=A9s=20en=20dessous=20#82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - overflow-x:scroll force le thumb orange toujours visible (pas overlay) - hauteur 16px pour le drag-and-drop - :has(.dt-hscroll:not(.is-hidden)) décale les th à top:16px quand la barre est présente, évitant le recouvrement Co-Authored-By: Claude Opus 4.8 --- src/assets/css/style.css | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index c62ad16..cb405a8 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -375,6 +375,11 @@ table.cell-table th { background-color: #fff; } +/* Quand la barre de scroll est visible, pousser les en-têtes en dessous */ +.marches_table:has(.dt-hscroll:not(.is-hidden)) th.dash-header { + top: 16px; +} + .marches_table .cell-table tr:nth-child(even) td { background-color: rgb(255 240 240 / 40%); } @@ -384,20 +389,20 @@ table.cell-table th { position: sticky; top: 0; z-index: 11; /* au-dessus des en-têtes sticky */ - overflow-x: auto; + overflow-x: scroll; /* scroll (pas auto) force le thumb toujours visible */ overflow-y: hidden; - height: 12px; + height: 16px; scrollbar-color: orange #e0e0e0; - scrollbar-width: thin; + scrollbar-width: auto; } .marches_table .dt-hscroll::-webkit-scrollbar { - height: 12px; + height: 16px; } .marches_table .dt-hscroll::-webkit-scrollbar-thumb { background-color: orange; - border-radius: 6px; + border-radius: 8px; } .marches_table .dt-hscroll::-webkit-scrollbar-track { From 9c5fc9257afc545eae50e9ffe91a23649e328ecc Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 18:50:51 +0200 Subject: [PATCH 13/18] fix(tableaux): thumb custom toujours visible, drag souris et tactile #82 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remplace le scrollbar natif (invisible en mode overlay sous Linux) par un thumb div orange toujours visible. Track gris (#e0e0e0) 16px. Drag souris + toucher + clic sur le track supportés. Supprime la dépendance aux pseudo-éléments webkit et scrollbar-color. Co-Authored-By: Claude Opus 4.8 --- src/assets/css/style.css | 38 ++++++------ src/assets/table_hscroll.js | 120 +++++++++++++++++++++++++++--------- 2 files changed, 109 insertions(+), 49 deletions(-) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index cb405a8..81f210c 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -389,34 +389,32 @@ table.cell-table th { position: sticky; top: 0; z-index: 11; /* au-dessus des en-têtes sticky */ - overflow-x: scroll; /* scroll (pas auto) force le thumb toujours visible */ - overflow-y: hidden; height: 16px; - scrollbar-color: orange #e0e0e0; - scrollbar-width: auto; -} - -.marches_table .dt-hscroll::-webkit-scrollbar { - height: 16px; -} - -.marches_table .dt-hscroll::-webkit-scrollbar-thumb { - background-color: orange; - border-radius: 8px; -} - -.marches_table .dt-hscroll::-webkit-scrollbar-track { background-color: #e0e0e0; -} - -.marches_table .dt-hscroll-inner { - height: 1px; + 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 index 3d518cd..b02e438 100644 --- a/src/assets/table_hscroll.js +++ b/src/assets/table_hscroll.js @@ -14,39 +14,101 @@ const bar = document.createElement("div"); bar.className = "dt-hscroll is-hidden"; - const inner = document.createElement("div"); - inner.className = "dt-hscroll-inner"; - bar.appendChild(inner); + const thumb = document.createElement("div"); + thumb.className = "dt-hscroll-thumb"; + bar.appendChild(thumb); wrapper.insertBefore(bar, wrapper.firstChild); - // La barre synchronise le scroll horizontal de la page (overflow: visible sur les - // conteneurs Dash laisse le scroll se faire au niveau de la page, ce qui permet - // aux en-têtes position:sticky de rester calés sur la fenêtre). - // syncing évite les boucles dans la même pile d'exécution. - let syncing = false; - const onBar = () => { - if (syncing) return; - syncing = true; - window.scrollTo(bar.scrollLeft, window.scrollY); - syncing = false; - }; - const onPage = () => { - if (syncing) return; - syncing = true; - bar.scrollLeft = window.scrollX; - syncing = false; - }; - bar.addEventListener("scroll", onBar); - // SPA : ce listener est intentionnellement conservé pour toute la durée de vie de la page. - window.addEventListener("scroll", onPage); - - const refresh = () => { + // --- Métriques communes --- + const metrics = () => { const total = document.documentElement.scrollWidth; const visible = window.innerWidth; - inner.style.width = total + "px"; - // +1 absorbe les erreurs d'arrondi sous-pixel - bar.classList.toggle("is-hidden", total <= visible + 1); - bar.scrollLeft = window.scrollX; + const trackW = bar.clientWidth; + const thumbW = Math.max(40, (visible / total) * trackW); + const scrollRange = total - visible; + const thumbRange = trackW - thumbW; + return { total, visible, trackW, thumbW, scrollRange, thumbRange }; + }; + + // --- Mise à jour de la position du thumb --- + 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 ? window.scrollX / 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 = window.scrollX; + }; + const moveDrag = (clientX) => { + if (dragStartX === null) return; + const dx = clientX - dragStartX; + const { scrollRange, thumbRange } = metrics(); + if (thumbRange <= 0) return; + window.scrollTo( + dragScrollStart + (dx / thumbRange) * scrollRange, + window.scrollY + ); + }; + 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) + ); + window.scrollTo(fraction * scrollRange, window.scrollY); + }); + + // Synchronise le thumb quand la page défile (via clavier, molette, etc.). + // SPA : ce listener est intentionnellement conservé pour toute la durée de vie de la page. + window.addEventListener("scroll", syncThumb); + + const refresh = () => { + const { total, visible } = metrics(); + const hasOverflow = total > visible + 1; + bar.classList.toggle("is-hidden", !hasOverflow); + if (hasOverflow) syncThumb(); }; // Recalcule quand le tableau change (pagination, tri, filtre, données). From 3c0cf6bc0743499af724e1f541e611c91ad7aff5 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 19:02:36 +0200 Subject: [PATCH 14/18] fix(tableaux): barre pleine largeur viewport + masquage par table #82 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - adjustBarWidth() brise le padding Bootstrap (margin-left négatif + width: 100vw) pour que la barre s'étende jusqu'au bord de la fenêtre - metrics() utilise window.innerWidth comme largeur de piste (cohérent avec la barre pleine largeur) - hasOverflow mesure la largeur de .cell-table de CETTE table via getBoundingClientRect(), plutôt que document.documentElement.scrollWidth → les petits tableaux (top10 /acheteur, /titulaire, /observatoire) n'affichent pas de barre s'ils ne débordent pas individuellement Co-Authored-By: Claude Opus 4.8 --- src/assets/table_hscroll.js | 41 +++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/src/assets/table_hscroll.js b/src/assets/table_hscroll.js index b02e438..3285316 100644 --- a/src/assets/table_hscroll.js +++ b/src/assets/table_hscroll.js @@ -19,15 +19,22 @@ bar.appendChild(thumb); wrapper.insertBefore(bar, wrapper.firstChild); - // --- Métriques communes --- + // Étend la barre à toute la largeur du viewport, hors du container Bootstrap. + // wrapper.getBoundingClientRect().left donne le décalage horizontal du conteneur. + const adjustBarWidth = () => { + const left = wrapper.getBoundingClientRect().left; + bar.style.marginLeft = -left + "px"; + bar.style.width = window.innerWidth + "px"; + }; + + // Métriques basées sur window.innerWidth (la barre occupe exactement la largeur du viewport). const metrics = () => { const total = document.documentElement.scrollWidth; const visible = window.innerWidth; - const trackW = bar.clientWidth; - const thumbW = Math.max(40, (visible / total) * trackW); + const thumbW = Math.max(40, (visible / total) * visible); const scrollRange = total - visible; - const thumbRange = trackW - thumbW; - return { total, visible, trackW, thumbW, scrollRange, thumbRange }; + const thumbRange = visible - thumbW; + return { total, visible, thumbW, scrollRange, thumbRange }; }; // --- Mise à jour de la position du thumb --- @@ -91,22 +98,31 @@ // 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) + Math.min( + 1, + (e.clientX - bar.getBoundingClientRect().left - thumbW / 2) / + thumbRange + ) ); window.scrollTo(fraction * scrollRange, window.scrollY); }); - // Synchronise le thumb quand la page défile (via clavier, molette, etc.). + // Synchronise le thumb quand la page défile (clavier, molette, barre native, etc.). // SPA : ce listener est intentionnellement conservé pour toute la durée de vie de la page. window.addEventListener("scroll", syncThumb); const refresh = () => { - const { total, visible } = metrics(); - const hasOverflow = total > visible + 1; + adjustBarWidth(); + // Mesure la largeur réelle de CETTE table (pas de la page entière) pour + // ne pas afficher la barre sur les petits tableaux d'une page qui a aussi + // un grand tableau (ex. top10 sur /acheteur, /titulaire, /observatoire). + const tableEl = + dashContainer.querySelector(".cell-table") || dashContainer; + const tableWidth = tableEl.getBoundingClientRect().width; + const hasOverflow = tableWidth > window.innerWidth + 1; bar.classList.toggle("is-hidden", !hasOverflow); if (hasOverflow) syncThumb(); }; @@ -119,7 +135,10 @@ attributes: true, }); // SPA : ce listener est intentionnellement conservé pour toute la durée de vie de la page. - window.addEventListener("resize", refresh); + window.addEventListener("resize", () => { + adjustBarWidth(); + refresh(); + }); refresh(); } From 2e22fcabe991f2501222e5345b8aaf6f87fdd1d5 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 19:20:41 +0200 Subject: [PATCH 15/18] fix(tableaux): scroll contenu dans dashContainer, plus de scrollbar page #82 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remplace le scroll de page (window.scrollTo) par dashContainer.scrollLeft : - overflow-x:hidden sur .dash-spreadsheet-container → la table ne déborde plus la page → scrollbar navigateur en bas éliminée - overflow-y:clip évite la conversion CSS visible→auto (pas de scrollbar vertical parasite sur le conteneur) - metrics(), syncThumb, drag et clic utilisent tous dashContainer - wheel handler capture les gestures trackpad horizontaux et les redirige vers dashContainer.scrollLeft Co-Authored-By: Claude Opus 4.8 --- src/assets/css/style.css | 10 ++++- src/assets/table_hscroll.js | 74 ++++++++++++++++++------------------- 2 files changed, 45 insertions(+), 39 deletions(-) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index 81f210c..9d05feb 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -361,8 +361,14 @@ table.cell-table th { /* ===== 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, +/* 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; } diff --git a/src/assets/table_hscroll.js b/src/assets/table_hscroll.js index 3285316..f59f142 100644 --- a/src/assets/table_hscroll.js +++ b/src/assets/table_hscroll.js @@ -19,30 +19,24 @@ bar.appendChild(thumb); wrapper.insertBefore(bar, wrapper.firstChild); - // Étend la barre à toute la largeur du viewport, hors du container Bootstrap. - // wrapper.getBoundingClientRect().left donne le décalage horizontal du conteneur. - const adjustBarWidth = () => { - const left = wrapper.getBoundingClientRect().left; - bar.style.marginLeft = -left + "px"; - bar.style.width = window.innerWidth + "px"; - }; - - // Métriques basées sur window.innerWidth (la barre occupe exactement la largeur du viewport). + // 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 = document.documentElement.scrollWidth; - const visible = window.innerWidth; + 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 --- + // 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 ? window.scrollX / scrollRange : 0; + const fraction = + scrollRange > 0 ? dashContainer.scrollLeft / scrollRange : 0; thumb.style.left = Math.round(fraction * thumbRange) + "px"; }; @@ -52,16 +46,16 @@ const startDrag = (clientX) => { dragStartX = clientX; - dragScrollStart = window.scrollX; + dragScrollStart = dashContainer.scrollLeft; }; const moveDrag = (clientX) => { if (dragStartX === null) return; const dx = clientX - dragStartX; const { scrollRange, thumbRange } = metrics(); if (thumbRange <= 0) return; - window.scrollTo( - dragScrollStart + (dx / thumbRange) * scrollRange, - window.scrollY + dashContainer.scrollLeft = Math.max( + 0, + Math.min(scrollRange, dragScrollStart + (dx / thumbRange) * scrollRange) ); }; const endDrag = () => { @@ -98,31 +92,40 @@ // 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 - bar.getBoundingClientRect().left - thumbW / 2) / - thumbRange - ) + Math.min(1, (e.clientX - rect.left - thumbW / 2) / thumbRange) ); - window.scrollTo(fraction * scrollRange, window.scrollY); + dashContainer.scrollLeft = fraction * scrollRange; }); - // Synchronise le thumb quand la page défile (clavier, molette, barre native, etc.). + // Scroll molette/trackpad horizontal → redirigé vers le conteneur. // SPA : ce listener est intentionnellement conservé pour toute la durée de vie de la page. - window.addEventListener("scroll", syncThumb); + 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 = () => { - adjustBarWidth(); - // Mesure la largeur réelle de CETTE table (pas de la page entière) pour - // ne pas afficher la barre sur les petits tableaux d'une page qui a aussi - // un grand tableau (ex. top10 sur /acheteur, /titulaire, /observatoire). - const tableEl = - dashContainer.querySelector(".cell-table") || dashContainer; - const tableWidth = tableEl.getBoundingClientRect().width; - const hasOverflow = tableWidth > window.innerWidth + 1; + const hasOverflow = + dashContainer.scrollWidth > dashContainer.clientWidth + 1; bar.classList.toggle("is-hidden", !hasOverflow); if (hasOverflow) syncThumb(); }; @@ -135,10 +138,7 @@ attributes: true, }); // SPA : ce listener est intentionnellement conservé pour toute la durée de vie de la page. - window.addEventListener("resize", () => { - adjustBarWidth(); - refresh(); - }); + window.addEventListener("resize", refresh); refresh(); } From 5e64a4553dd11aceec07c7a3d9f8bb80acf55a86 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 19:46:43 +0200 Subject: [PATCH 16/18] fix(tableaux): supprimer sticky headers, barre 12px #82 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Retire position:sticky/z-index sur th.dash-header (abandonnés avec le scroll contenu : overflow-x:hidden crée un scroll container qui empêche le sticky page-level, et le z-index provoquait un recouvrement des champs de filtre) - Retire la règle :has() devenue inutile - Barre de scroll réduite à 12px Co-Authored-By: Claude Opus 4.8 --- src/assets/css/style.css | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index 9d05feb..d4a5056 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -373,29 +373,13 @@ table.cell-table th { 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; -} - -/* Quand la barre de scroll est visible, pousser les en-têtes en dessous */ -.marches_table:has(.dt-hscroll:not(.is-hidden)) th.dash-header { - top: 16px; -} - .marches_table .cell-table tr:nth-child(even) td { background-color: rgb(255 240 240 / 40%); } -/* Barre de défilement horizontale, collée en haut au-dessus des en-têtes */ +/* Barre de défilement horizontale en haut du tableau */ .marches_table .dt-hscroll { - position: sticky; - top: 0; - z-index: 11; /* au-dessus des en-têtes sticky */ - height: 16px; + height: 12px; background-color: #e0e0e0; overflow: hidden; cursor: pointer; From 45526f7c7b449a4db8e77058dc4f7a34737f0ad6 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 19:52:42 +0200 Subject: [PATCH 17/18] fix(tableaux): restaurer position:sticky sur la barre, 12px #82 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le retrait de position:sticky sur .dt-hscroll avait cassé le layout (bouton Remettre à zéro visible au mauvais endroit, z-index perdu). La barre redevient sticky top:0 z-index:11 — les th.dash-header restent sans sticky ni z-index (ils ne fonctionnaient de toute façon pas en scroll contenu). Co-Authored-By: Claude Opus 4.8 --- src/assets/css/style.css | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index d4a5056..50124ab 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -377,8 +377,11 @@ table.cell-table th { background-color: rgb(255 240 240 / 40%); } -/* Barre de défilement horizontale en haut du tableau */ +/* 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; From f508c821c2fb7ff281b4a619744e687637df13d2 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 20:00:41 +0200 Subject: [PATCH 18/18] test(tableaux): adapter test post-abandon sticky headers #82 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le test ne vérifie plus position:sticky sur th (sticky abandonné car incompatible avec scroll contenu). Vérifie à la place que overflow-x:hidden est bien en place sur le conteneur Dash — garantit l'absence de scrollbar navigateur en bas de page. Co-Authored-By: Claude Opus 4.8 --- tests/test_tableau_hscroll.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/test_tableau_hscroll.py b/tests/test_tableau_hscroll.py index 6699c89..8bd9920 100644 --- a/tests/test_tableau_hscroll.py +++ b/tests/test_tableau_hscroll.py @@ -2,15 +2,17 @@ from dash.testing.composite import DashComposite def test_tableau_hscroll_bar_present(dash_duo: DashComposite): - """La barre miroir est injectée et l'en-tête est collant sur /tableau.""" + """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 miroir injectée par table_hscroll.js + # 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) - header = dash_duo.find_element(".marches_table th.dash-header") - position = header.value_of_css_property("position") - assert position == "sticky" + # 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"