1.5
Code refactoring Profile fixes Table fixes Animation additions
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// useTableScroll — owns the horizontal scroll behaviour of the hourly
|
||||
// table (sticky header + body scroller layout).
|
||||
//
|
||||
// Responsibilities:
|
||||
// 1. SCROLL INDICATORS — track whether the body can scroll left/right so
|
||||
// the table edges can show fade + chevron indicators.
|
||||
// 2. DRAG-TO-SCROLL — pointer-event drag on the body scroller for
|
||||
// desktop users.
|
||||
// 3. SCROLL SYNC — keep the sticky header track shifted horizontally to
|
||||
// match the body's scrollLeft, and keep header cell widths in lock-
|
||||
// step with body cell widths even as columns toggle / window resizes.
|
||||
//
|
||||
// Inputs (passed by app.js):
|
||||
// refs: { headTableRef, bodyTableRef, bodyScrollRef, headTrackRef }
|
||||
// deps: { forecast, visibleCols, selectedDay, skinType, vehicleType }
|
||||
// — anything that should cause a re-sync when it changes.
|
||||
//
|
||||
// Outputs:
|
||||
// tableCanScrollLeft, tableCanScrollRight → drive the fade/chevron CSS
|
||||
// handleBodyScroll → attach to body onScroll
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
import { useState, useEffect, useLayoutEffect } from '../../vendor/preact-hooks.js';
|
||||
|
||||
export function useTableScroll({
|
||||
headTableRef,
|
||||
bodyTableRef,
|
||||
bodyScrollRef,
|
||||
headTrackRef,
|
||||
forecast,
|
||||
visibleCols,
|
||||
selectedDay,
|
||||
skinType,
|
||||
vehicleType,
|
||||
}) {
|
||||
// ─── SCROLL INDICATORS ─────────────────────────────────────────────
|
||||
const [tableCanScrollLeft, setTableCanScrollLeft] = useState(false);
|
||||
const [tableCanScrollRight, setTableCanScrollRight] = useState(false);
|
||||
|
||||
const updateTableScrollIndicators = () => {
|
||||
const el = bodyScrollRef.current;
|
||||
if (!el) return;
|
||||
setTableCanScrollLeft(el.scrollLeft > 1);
|
||||
setTableCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
|
||||
};
|
||||
|
||||
// ─── DRAG-TO-SCROLL ────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const el = bodyScrollRef.current;
|
||||
if (!el) return;
|
||||
let isDown = false;
|
||||
let startX = 0;
|
||||
let startScroll = 0;
|
||||
|
||||
const onMouseDown = (e) => {
|
||||
// Only act on clicks that land inside the body scroller
|
||||
if (!el.contains(e.target)) return;
|
||||
if (e.button !== 0) return;
|
||||
if (e.target.closest('button, a, input, select')) return;
|
||||
isDown = true;
|
||||
startX = e.clientX;
|
||||
startScroll = el.scrollLeft;
|
||||
el.style.cursor = 'grabbing';
|
||||
document.body.style.userSelect = 'none';
|
||||
document.body.style.webkitUserSelect = 'none';
|
||||
};
|
||||
const onMouseMove = (e) => {
|
||||
if (!isDown) return;
|
||||
const dx = e.clientX - startX;
|
||||
el.scrollLeft = startScroll - dx;
|
||||
};
|
||||
const onMouseUp = () => {
|
||||
if (!isDown) return;
|
||||
isDown = false;
|
||||
el.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
document.body.style.webkitUserSelect = '';
|
||||
};
|
||||
|
||||
// Attach everything to document so Preact's synthetic event system
|
||||
// cannot intercept or swallow the events before we see them.
|
||||
document.addEventListener('mousedown', onMouseDown);
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
|
||||
// Also update indicators on scroll
|
||||
el.addEventListener('scroll', updateTableScrollIndicators);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onMouseDown);
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
el.removeEventListener('scroll', updateTableScrollIndicators);
|
||||
};
|
||||
}, [forecast]);
|
||||
|
||||
// Update indicators after layout sync (columns may have changed width)
|
||||
useEffect(() => {
|
||||
updateTableScrollIndicators();
|
||||
}, [forecast, visibleCols, selectedDay]);
|
||||
|
||||
// ─── BODY SCROLL HANDLER (called from JSX onScroll) ───────────────
|
||||
const handleBodyScroll = () => {
|
||||
const track = headTrackRef.current;
|
||||
const body = bodyScrollRef.current;
|
||||
if (!track || !body) return;
|
||||
track.style.transform = `translate3d(${-body.scrollLeft}px, 0, 0)`;
|
||||
updateTableScrollIndicators();
|
||||
};
|
||||
|
||||
// ─── COLUMN-WIDTH SCROLL SYNC (layout effect) ─────────────────────
|
||||
// Synchronise the head and body table column widths with a
|
||||
// "shrink-to-fit then distribute" strategy. See the comments inside
|
||||
// sync() for the algorithm.
|
||||
useLayoutEffect(() => {
|
||||
const sync = () => {
|
||||
const headTable = headTableRef.current;
|
||||
const bodyTable = bodyTableRef.current;
|
||||
const bodyScroll = bodyScrollRef.current;
|
||||
if (!headTable || !bodyTable || !bodyScroll) return;
|
||||
const bodyRow = bodyTable.querySelector('tbody tr');
|
||||
const headRow = headTable.querySelector('thead tr');
|
||||
if (!bodyRow || !headRow) return;
|
||||
const headCells = Array.from(headRow.children);
|
||||
const bodyCells = Array.from(bodyRow.children);
|
||||
const n = Math.min(headCells.length, bodyCells.length);
|
||||
if (n === 0) return;
|
||||
|
||||
// Step 1: clear any previously-forced cell widths and switch the
|
||||
// tables to natural sizing so the measurement reflects the true
|
||||
// content-fit width — independent of how wide the container is.
|
||||
headCells.forEach(c => { c.style.width = ''; c.style.minWidth = ''; c.style.maxWidth = ''; });
|
||||
bodyCells.forEach(c => { c.style.width = ''; c.style.minWidth = ''; c.style.maxWidth = ''; });
|
||||
headTable.style.width = 'max-content';
|
||||
bodyTable.style.width = 'max-content';
|
||||
headTable.style.tableLayout = 'auto';
|
||||
bodyTable.style.tableLayout = 'auto';
|
||||
|
||||
// Step 2: read each cell's natural width. getBoundingClientRect
|
||||
// forces synchronous layout — that's what we want.
|
||||
const naturalW = new Array(n);
|
||||
let naturalTotal = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const headW = headCells[i].getBoundingClientRect().width;
|
||||
const bodyW = bodyCells[i].getBoundingClientRect().width;
|
||||
const w = Math.max(Math.ceil(headW), Math.ceil(bodyW));
|
||||
naturalW[i] = w;
|
||||
naturalTotal += w;
|
||||
}
|
||||
|
||||
// Step 3: decide final widths based on available container width.
|
||||
const containerW = bodyScroll.clientWidth;
|
||||
const finalW = new Array(n);
|
||||
let totalWidth;
|
||||
if (naturalTotal > 0 && naturalTotal < containerW) {
|
||||
// Spare space — distribute proportionally across columns so they
|
||||
// fan out to fill the scroller (no awkward right-hand gap).
|
||||
const scale = containerW / naturalTotal;
|
||||
let running = 0;
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
finalW[i] = Math.floor(naturalW[i] * scale);
|
||||
running += finalW[i];
|
||||
}
|
||||
// Absorb sub-pixel rounding into the last column so the total
|
||||
// exactly matches the container width.
|
||||
finalW[n - 1] = containerW - running;
|
||||
totalWidth = containerW;
|
||||
} else {
|
||||
// Naturals don't fit — use them as-is and let the body scroll.
|
||||
for (let i = 0; i < n; i++) finalW[i] = naturalW[i];
|
||||
totalWidth = naturalTotal;
|
||||
}
|
||||
|
||||
// Step 4: restore the CSS-defined table-layout: fixed so the
|
||||
// explicit cell widths we apply below are honoured by the browser
|
||||
// (not redistributed by the auto-layout algorithm).
|
||||
headTable.style.tableLayout = '';
|
||||
bodyTable.style.tableLayout = '';
|
||||
|
||||
// Step 5: apply the final width to both head and body cells.
|
||||
for (let i = 0; i < n; i++) {
|
||||
const px = `${finalW[i]}px`;
|
||||
headCells[i].style.width = px;
|
||||
headCells[i].style.minWidth = px;
|
||||
headCells[i].style.maxWidth = px;
|
||||
bodyCells[i].style.width = px;
|
||||
bodyCells[i].style.minWidth = px;
|
||||
bodyCells[i].style.maxWidth = px;
|
||||
}
|
||||
// Make both tables exactly totalWidth wide so they share the same
|
||||
// horizontal extent — column N in the header sits directly above
|
||||
// column N in the body, no drift as you scroll right.
|
||||
headTable.style.width = `${totalWidth}px`;
|
||||
bodyTable.style.width = `${totalWidth}px`;
|
||||
// Re-apply current horizontal offset so column alignment survives.
|
||||
handleBodyScroll();
|
||||
};
|
||||
// Run once after layout
|
||||
sync();
|
||||
// Re-sync when the scroll container's width changes (window resize,
|
||||
// sidebar opens, etc). We observe the scroller — not the body table —
|
||||
// because the body table's width is now driven by sync itself, which
|
||||
// would otherwise create a feedback loop.
|
||||
let ro = null;
|
||||
if (typeof ResizeObserver !== 'undefined' && bodyScrollRef.current) {
|
||||
ro = new ResizeObserver(sync);
|
||||
ro.observe(bodyScrollRef.current);
|
||||
}
|
||||
window.addEventListener('resize', sync);
|
||||
return () => {
|
||||
if (ro) ro.disconnect();
|
||||
window.removeEventListener('resize', sync);
|
||||
};
|
||||
}, [forecast, visibleCols, selectedDay, skinType, vehicleType]);
|
||||
|
||||
return {
|
||||
tableCanScrollLeft,
|
||||
tableCanScrollRight,
|
||||
handleBodyScroll,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user