feat(tableaux): scroll horizontal ergonomique + barre custom (#82)
- Barre de défilement JS orange (12px) injectée en haut de chaque .marches_table, collée avec position:sticky, masquée si le tableau tient dans le viewport - Scroll contenu dans .dash-spreadsheet-container (overflow-x:hidden) → plus de scrollbar navigateur en bas de page - Drag souris + tactile, clic sur le track, molette/trackpad - Recalcul automatique au resize et aux re-renders Dash (MutationObserver) - Test Selenium de non-régression (overflow-x:hidden sur le conteneur) Sticky headers abandonnés (incompatibles avec scroll contenu). Portée : 4 pages via la classe partagée .marches_table. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
Reference in New Issue
Block a user